Initial commit: Modularized Strava MCP Server with UV and Hatchling

This commit is contained in:
2026-05-09 01:06:04 +02:00
commit ed43e1928e
21 changed files with 2688 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Strava MCP Server"""
__version__ = "0.1.0"
+125
View File
@@ -0,0 +1,125 @@
"""
Strava OAuth2 Authorization Helper
This script guides you through the Strava OAuth2 flow to obtain a refresh token
with the correct scopes for the MCP server.
Required scopes:
- read → basic athlete profile
- activity:read → read your activities
- activity:read_all → read private activities (optional)
Usage: uv run get_token.py
"""
import os
import webbrowser
import httpx
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.getenv("STRAVA_CLIENT_ID")
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):
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"""
<html><body style="font-family:sans-serif;text-align:center;padding:40px">
<h2>&#x2705; Authorization successful!</h2>
<p>You can close this window and return to your terminal.</p>
</body></html>
""")
else:
error = params.get("error", ["unknown"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(f"<html><body><h2>&#x274C; Error: {error}</h2></body></html>".encode())
def log_message(self, format, *args):
pass # Suppress server logs
def main():
if not CLIENT_ID or not CLIENT_SECRET:
print("❌ Missing STRAVA_CLIENT_ID or STRAVA_CLIENT_SECRET in .env")
return
auth_url = (
f"https://www.strava.com/oauth/authorize"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&response_type=code"
f"&approval_prompt=force"
f"&scope={SCOPES}"
)
print("=" * 60)
print(" Strava OAuth2 Authorization")
print("=" * 60)
print(f"\nRequesting scopes: {SCOPES}\n")
print("Opening Strava in your browser...")
print("If the browser doesn't open, visit this URL manually:\n")
print(f" {auth_url}\n")
webbrowser.open(auth_url)
print("Waiting for callback on http://localhost:8765 ...")
server = HTTPServer(("localhost", 8765), CallbackHandler)
server.handle_request() # Handle exactly one request (the callback)
if not auth_code:
print("❌ No authorization code received.")
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}")
return
data = response.json()
refresh_token = data["refresh_token"]
athlete = data.get("athlete", {})
print("\n" + "=" * 60)
print(" ✅ Authorization successful!")
print("=" * 60)
print(f"\nAthlete: {athlete.get('firstname')} {athlete.get('lastname')}")
print(f"Scopes granted: {data.get('scope', 'unknown')}\n")
print("Add the following to your .env file:")
print("-" * 40)
print(f"STRAVA_CLIENT_ID={CLIENT_ID}")
print(f"STRAVA_CLIENT_SECRET={CLIENT_SECRET}")
print(f"STRAVA_REFRESH_TOKEN={refresh_token}")
print("-" * 40)
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
import os
import sys
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.tools import register_tools
load_dotenv()
def validate_credentials() -> None:
"""Validates that all required Strava credentials are present. Exits on failure."""
required = {
"STRAVA_CLIENT_ID": os.getenv("STRAVA_CLIENT_ID"),
"STRAVA_CLIENT_SECRET": os.getenv("STRAVA_CLIENT_SECRET"),
"STRAVA_REFRESH_TOKEN": os.getenv("STRAVA_REFRESH_TOKEN"),
}
missing = [k for k, v in required.items() if not v]
if missing:
print("❌ Startup aborted: Missing required environment variables:")
for key in missing:
print(f" - {key}")
print("\nCopy .env.example to .env and fill in your Strava API credentials.")
sys.exit(1)
def main() -> None:
"""Entry point for the strava-mcp console script."""
load_dotenv()
validate_credentials()
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", 8000))
strava = StravaClient()
mcp = FastMCP(
"Strava MCP Server",
host=host,
port=port,
streamable_http_path="/mcp",
stateless_http=True,
)
mcp._mcp_server.version = "0.1.0"
register_tools(mcp, strava)
print(f"🚀 Starting Strava MCP Server on http://{host}:{port}")
print(f" MCP endpoint: http://{host}:{port}/mcp (Streamable HTTP)")
try:
mcp.run(transport="streamable-http")
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
import httpx
import os
import time
from typing import Optional, Dict, Any, List
from dotenv import load_dotenv
load_dotenv()
class StravaClient:
def __init__(self):
self.client_id = os.getenv("STRAVA_CLIENT_ID")
self.client_secret = os.getenv("STRAVA_CLIENT_SECRET")
self.refresh_token = os.getenv("STRAVA_REFRESH_TOKEN")
self.access_token: Optional[str] = None
self.expires_at: int = 0
self.base_url = "https://www.strava.com/api/v3"
async def _refresh_access_token(self):
"""Refreshes the access token using the refresh token."""
async with httpx.AsyncClient() as client:
response = await client.post(
"https://www.strava.com/oauth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
},
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
self.refresh_token = data["refresh_token"]
self.expires_at = data["expires_at"]
async def get_valid_token(self) -> str:
"""Returns a valid access token, refreshing it if necessary."""
if not self.access_token or time.time() > self.expires_at - 60:
await self._refresh_access_token()
return self.access_token
async def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""Helper for authenticated GET requests to the Strava API."""
token = await self.get_valid_token()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/{endpoint}",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
response.raise_for_status()
return response.json()
# ── Athletes ──────────────────────────────────────────────────────────────
async def get_athlete(self) -> Dict[str, Any]:
"""Gets the current athlete profile."""
return await self._get("athlete")
async def get_athlete_zones(self) -> Dict[str, Any]:
"""Gets heart rate and power zones configured for the current athlete."""
return await self._get("athlete/zones")
async def get_athlete_stats(self) -> Dict[str, Any]:
"""Gets cumulative stats for the current athlete (totals for runs, rides, swims)."""
athlete = await self.get_athlete()
return await self._get(f"athletes/{athlete['id']}/stats")
# ── Activities ────────────────────────────────────────────────────────────
async def list_activities(
self,
limit: int = 10,
page: int = 1,
before: int | None = None,
after: int | None = None,
) -> List[Dict[str, Any]]:
"""Lists activities for the current athlete."""
params: Dict[str, Any] = {"per_page": limit, "page": page}
if before is not None:
params["before"] = before
if after is not None:
params["after"] = after
return await self._get("athlete/activities", params=params)
async def get_activity(self, activity_id: int) -> Dict[str, Any]:
"""Gets a specific activity by ID."""
return await self._get(f"activities/{activity_id}")
async def get_activity_laps(self, activity_id: int) -> List[Dict[str, Any]]:
"""Gets the laps for a specific activity."""
return await self._get(f"activities/{activity_id}/laps")
async def get_activity_zones(self, activity_id: int) -> List[Dict[str, Any]]:
"""Gets the heart rate and power zones for a specific activity."""
return await self._get(f"activities/{activity_id}/zones")
async def get_activity_comments(self, activity_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets comments on a specific activity."""
return await self._get(f"activities/{activity_id}/comments", params={"page": page, "per_page": per_page})
async def get_activity_kudoers(self, activity_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets athletes who kudoed a specific activity."""
return await self._get(f"activities/{activity_id}/kudos", params={"page": page, "per_page": per_page})
async def get_activity_streams(self, activity_id: int, keys: List[str]) -> Dict[str, Any]:
"""Gets data streams for a specific activity."""
return await self._get(
f"activities/{activity_id}/streams",
params={"keys": ",".join(keys), "key_by_type": True},
)
# ── Clubs ─────────────────────────────────────────────────────────────────
async def get_athlete_clubs(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets clubs the current athlete belongs to."""
return await self._get("athlete/clubs", params={"page": page, "per_page": per_page})
async def get_club(self, club_id: int) -> Dict[str, Any]:
"""Gets a specific club by ID."""
return await self._get(f"clubs/{club_id}")
async def get_club_activities(self, club_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets recent activities from a club."""
return await self._get(f"clubs/{club_id}/activities", params={"page": page, "per_page": per_page})
async def get_club_members(self, club_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets members of a club."""
return await self._get(f"clubs/{club_id}/members", params={"page": page, "per_page": per_page})
# ── Routes ────────────────────────────────────────────────────────────────
async def get_routes_by_athlete(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets routes created by the current athlete."""
athlete = await self.get_athlete()
return await self._get(f"athletes/{athlete['id']}/routes", params={"page": page, "per_page": per_page})
async def get_route_by_id(self, route_id: str) -> Dict[str, Any]:
"""Gets a specific route by its ID."""
return await self._get(f"routes/{route_id}")
async def get_route_streams(self, route_id: str) -> Dict[str, Any]:
"""Gets data streams for a specific route."""
return await self._get(f"routes/{route_id}/streams")
# ── Segments ──────────────────────────────────────────────────────────────
async def get_segment(self, segment_id: int) -> Dict[str, Any]:
"""Gets a specific segment by ID."""
return await self._get(f"segments/{segment_id}")
async def get_starred_segments(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
"""Gets segments starred by the current athlete."""
return await self._get("segments/starred", params={"page": page, "per_page": per_page})
async def explore_segments(self, bounds: str, activity_type: str = "riding") -> Dict[str, Any]:
"""Explores segments within a given bounding box."""
return await self._get(
"segments/explore",
params={"bounds": bounds, "activity_type": activity_type},
)
async def get_segment_streams(self, segment_id: int, keys: List[str]) -> Dict[str, Any]:
"""Gets data streams for a specific segment."""
return await self._get(
f"segments/{segment_id}/streams",
params={"keys": ",".join(keys), "key_by_type": True},
)
# ── Segment Efforts ───────────────────────────────────────────────────────
async def get_segment_effort(self, effort_id: str) -> Dict[str, Any]:
"""Gets a specific segment effort by ID."""
return await self._get(f"segment_efforts/{effort_id}")
async def list_segment_efforts(
self,
segment_id: int,
start_date_local: Optional[str] = None,
end_date_local: Optional[str] = None,
per_page: int = 30,
) -> List[Dict[str, Any]]:
"""Lists efforts on a segment for the current athlete."""
params: Dict[str, Any] = {"segment_id": segment_id, "per_page": per_page}
if start_date_local:
params["start_date_local"] = start_date_local
if end_date_local:
params["end_date_local"] = end_date_local
return await self._get("segment_efforts", params=params)
async def get_segment_effort_streams(self, effort_id: str, keys: List[str]) -> Dict[str, Any]:
"""Gets data streams for a specific segment effort."""
return await self._get(
f"segment_efforts/{effort_id}/streams",
params={"keys": ",".join(keys), "key_by_type": True},
)
# ── Gear ──────────────────────────────────────────────────────────────────
async def get_gear_by_id(self, gear_id: str) -> Dict[str, Any]:
"""Gets details for a specific piece of gear (bike or shoes) by its ID."""
return await self._get(f"gear/{gear_id}")
+29
View File
@@ -0,0 +1,29 @@
"""
MCP Tool definitions for the Strava MCP Server.
Register all tools by calling register_tools(mcp, strava).
"""
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
from . import athlete
from . import activities
from . import clubs
from . import routes
from . import segments
from . import segment_efforts
from . import gear
from . import prompts
from . import auth
def register_tools(mcp: FastMCP, strava: StravaClient) -> None:
"""Register all available tools and prompts."""
athlete.register(mcp, strava)
activities.register(mcp, strava)
clubs.register(mcp, strava)
routes.register(mcp, strava)
segments.register(mcp, strava)
segment_efforts.register(mcp, strava)
gear.register(mcp, strava)
prompts.register(mcp, strava)
auth.register(mcp, strava)
+219
View File
@@ -0,0 +1,219 @@
import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def list_activities(
ctx: Context,
limit: int = 10,
page: int = 1,
before: int | None = None,
after: int | None = None,
) -> list[TextContent]:
"""
List recent Strava activities for the authenticated user.
:param limit: Number of activities to return per page (default 10, max 200).
:param page: Page number for pagination (default 1).
:param before: Unix timestamp — only return activities before this time.
:param after: Unix timestamp — only return activities after this time.
Example: 1704067200 = 2024-01-01 00:00:00 UTC.
"""
try:
await ctx.info(f"Fetching activities (Page {page}, Limit {limit})...")
activities = await strava.list_activities(
limit=min(limit, 200),
page=page,
before=before,
after=after,
)
from datetime import datetime
def format_date(d_str):
if not d_str:
return "N/A"
try:
d = datetime.fromisoformat(d_str.replace('Z', '+00:00'))
return f"{d.day:02d}.{d.month:02d}.{d.year} {d.hour:02d}:{d.minute:02d}"
except Exception:
return d_str
essential_data = []
for a in activities:
essential_data.append({
"id": a["id"],
"name": a["name"],
"sport_type": a.get("sport_type") or a.get("type"),
"start_date": format_date(a.get("start_date")),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"average_heartrate": a.get("average_heartrate"),
"gear_id": a.get("gear_id"),
})
if not essential_data:
markdown_summary = "### 📭 Keine Aktivitäten in diesem Zeitraum gefunden."
else:
markdown_summary = f"### 🚴 Aktivitäten (Seite {page})\n"
markdown_summary += "| Datum | Sport | Name | Distanz | Zeit | Höhenmeter | Ø HR |\n"
markdown_summary += "|-------|-------|------|---------|------|------------|------|\n"
for a in essential_data:
hr = f"{a['average_heartrate']:.0f} bpm" if a['average_heartrate'] else "-"
markdown_summary += f"| {a['start_date']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n"
return [
TextContent(type="text", text=markdown_summary.strip()),
TextContent(
type="text",
text=f"Raw Data for Analysis: {json.dumps(essential_data, indent=2)}"
)
]
except Exception as e:
error_msg = f"Error listing activities: {str(e)}"
await ctx.error(error_msg)
return [TextContent(type="text", text=error_msg)]
@mcp.tool()
async def get_activity_details(activity_id: int):
"""
Get detailed information about a specific Strava activity by its ID.
:param activity_id: The numeric ID of the Strava activity.
Returns full activity data including segment efforts with string IDs safe for follow-up calls.
"""
try:
activity = await strava.get_activity(activity_id)
if "segment_efforts" in activity:
for effort in activity["segment_efforts"]:
if "id" in effort:
effort["id"] = str(effort["id"])
segment = effort.get("segment")
if segment and "id" in segment:
segment["id"] = str(segment["id"])
if "id" in activity:
activity["id"] = str(activity["id"])
return activity
except Exception as e:
return f"Error fetching activity details: {str(e)}"
@mcp.tool()
async def get_activity_comments(activity_id: int, limit: int = 30):
"""
List comments on a specific Strava activity.
:param activity_id: The numeric ID of the Strava activity.
:param limit: Number of comments to return (default 30).
"""
try:
comments = await strava.get_activity_comments(activity_id, per_page=limit)
return [
{
"id": c.get("id"),
"text": c.get("text"),
"athlete": f"{c.get('athlete', {}).get('firstname')} {c.get('athlete', {}).get('lastname')}",
"created_at": c.get("created_at"),
}
for c in comments
]
except Exception as e:
return f"Error fetching comments: {str(e)}"
@mcp.tool()
async def get_activity_kudoers(activity_id: int, limit: int = 30):
"""
List athletes who gave kudos on a specific Strava activity.
:param activity_id: The numeric ID of the Strava activity.
:param limit: Number of kudoers to return (default 30).
"""
try:
kudoers = await strava.get_activity_kudoers(activity_id, per_page=limit)
return [
{
"id": k.get("id"),
"name": f"{k.get('firstname')} {k.get('lastname')}",
"city": k.get("city"),
"country": k.get("country"),
}
for k in kudoers
]
except Exception as e:
return f"Error fetching kudoers: {str(e)}"
@mcp.tool()
async def get_laps_by_activity_id(activity_id: int):
"""
List all laps for a specific Strava activity.
:param activity_id: The numeric ID of the Strava activity.
Returns lap number, name, distance, moving time, average speed, and elevation gain.
"""
try:
laps = await strava.get_activity_laps(activity_id)
return [
{
"lap_index": lap.get("lap_index"),
"name": lap.get("name"),
"distance": f"{lap.get('distance', 0) / 1000:.3f} km",
"moving_time": f"{lap.get('moving_time', 0) / 60:.1f} min",
"elapsed_time": f"{lap.get('elapsed_time', 0) / 60:.1f} min",
"average_speed": f"{lap.get('average_speed', 0) * 3.6:.2f} km/h",
"max_speed": f"{lap.get('max_speed', 0) * 3.6:.2f} km/h",
"elevation_gain": f"{lap.get('total_elevation_gain', 0):.0f} m",
"average_heartrate": lap.get("average_heartrate"),
"max_heartrate": lap.get("max_heartrate"),
}
for lap in laps
]
except Exception as e:
return f"Error fetching laps: {str(e)}"
@mcp.tool()
async def get_zones_by_activity_id(activity_id: int):
"""
Get heart rate and power zones distribution for a specific Strava activity.
:param activity_id: The numeric ID of the Strava activity.
Returns zone type, score, and time spent in each zone bucket.
"""
try:
zones = await strava.get_activity_zones(activity_id)
result = []
for zone in zones:
buckets = [
{
"zone": i + 1,
"min": b.get("min", 0),
"max": b.get("max", -1),
"time_in_zone": f"{b.get('time', 0) / 60:.1f} min",
}
for i, b in enumerate(zone.get("distribution_buckets", []))
]
result.append({
"type": zone.get("type", "unknown"),
"sensor_based": zone.get("sensor_based", False),
"score": zone.get("score"),
"custom_zones": zone.get("custom_zones", False),
"points": zone.get("points"),
"distribution_buckets": buckets,
})
return result
except Exception as e:
return f"Error fetching activity zones: {str(e)}"
@mcp.tool()
async def get_activity_streams(
activity_id: int,
keys: str = "time,distance,heartrate,altitude,velocity_smooth,cadence,watts",
):
"""
Get raw data streams for a specific Strava activity.
:param activity_id: The numeric ID of the Strava activity.
:param keys: Comma-separated list of stream types to fetch.
Available: time, distance, latlng, altitude, velocity_smooth,
heartrate, cadence, watts, temp, moving, grade_smooth.
Returns one data array per requested stream type.
"""
try:
key_list = [k.strip() for k in keys.split(",") if k.strip()]
return await strava.get_activity_streams(activity_id, key_list)
except Exception as e:
return f"Error fetching activity streams: {str(e)}"
+124
View File
@@ -0,0 +1,124 @@
import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def get_athlete_profile(ctx: Context) -> list[TextContent]:
"""
Get the authenticated Strava athlete's profile.
Returns name, city, country, follower count, and other profile details.
"""
try:
await ctx.info("Fetching athlete profile...")
athlete = await strava.get_athlete()
from datetime import datetime
def format_date(d_str):
if not d_str:
return "N/A"
try:
d = datetime.fromisoformat(d_str.replace('Z', '+00:00'))
return f"{d.day}.{d.month}.{d.year}"
except Exception:
return d_str
location_parts = [p for p in (athlete.get('city'), athlete.get('state'), athlete.get('country')) if p]
location = ", ".join(location_parts) if location_parts else "N/A"
essential_data = {
"id": athlete.get("id"),
"username": athlete.get("username"),
"name": f"{athlete.get('firstname')} {athlete.get('lastname')}".strip(),
"location": location,
"sex": athlete.get("sex"),
"weight": athlete.get("weight"),
"measurement_units": athlete.get("measurement_preference"),
"is_premium": athlete.get("premium", False),
"profile_medium": athlete.get("profile_medium"),
"created_at": athlete.get("created_at"),
"updated_at": athlete.get("updated_at"),
"bio": athlete.get("bio"),
"follower_count": athlete.get("follower_count"),
"friend_count": athlete.get("friend_count"),
}
markdown_summary = f"""
👤 **Profile for {essential_data['name']}** (ID: {essential_data['id']})
- Username: {essential_data['username'] or 'N/A'}
- Location: {essential_data['location']}
- Sex: {essential_data['sex'] or 'N/A'}
- Weight: {essential_data['weight'] or 'N/A'} kg
- Measurement Units: {essential_data['measurement_units'] or 'N/A'}
- Strava Summit Member: {'Yes' if essential_data['is_premium'] else 'No'}
- Profile Image (Medium): {essential_data['profile_medium'] or 'N/A'}
- Joined Strava: {format_date(essential_data['created_at'])}
- Last Updated: {format_date(essential_data['updated_at'])}
""".strip()
return [
TextContent(type="text", text=markdown_summary),
TextContent(
type="text",
text=f"Raw Data for Analysis: {json.dumps(essential_data, indent=2)}"
)
]
except Exception as e:
error_msg = f"Error fetching athlete profile: {str(e)}"
await ctx.error(error_msg)
return [TextContent(type="text", text=error_msg)]
@mcp.tool()
async def get_athlete_zones():
"""
Get the heart rate and power zones configured for the authenticated athlete.
Returns zone boundaries for both heart rate and power (if a power meter is configured).
"""
try:
return await strava.get_athlete_zones()
except Exception as e:
return f"Error fetching athlete zones: {str(e)}"
@mcp.tool()
async def get_athlete_stats():
"""
Get cumulative training statistics for the authenticated Strava athlete.
Includes all-time, year-to-date, and recent (4-week) totals for runs, rides, and swims.
"""
import json
try:
stats = await strava.get_athlete_stats()
def fmt_sport(s: dict) -> dict:
return {
"count": s.get("count", 0),
"distance": f"{s.get('distance', 0) / 1000:.1f} km",
"moving_time": f"{s.get('moving_time', 0) / 3600:.1f} h",
"elevation_gain": f"{s.get('elevation_gain', 0):.0f} m",
}
result = {
"all_time": {
"runs": fmt_sport(stats.get("all_run_totals", {})),
"rides": fmt_sport(stats.get("all_ride_totals", {})),
"swims": fmt_sport(stats.get("all_swim_totals", {})),
},
"ytd": {
"runs": fmt_sport(stats.get("ytd_run_totals", {})),
"rides": fmt_sport(stats.get("ytd_ride_totals", {})),
"swims": fmt_sport(stats.get("ytd_swim_totals", {})),
},
"recent_4_weeks": {
"runs": fmt_sport(stats.get("recent_run_totals", {})),
"rides": fmt_sport(stats.get("recent_ride_totals", {})),
"swims": fmt_sport(stats.get("recent_swim_totals", {})),
},
}
return json.dumps(result, indent=2)
except Exception as e:
return f"Error fetching athlete stats: {str(e)}"
+121
View File
@@ -0,0 +1,121 @@
import os
import webbrowser
import httpx
import asyncio
from http.server import HTTPServer, BaseHTTPRequestHandler
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):
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"""
<html><body style="font-family:sans-serif;text-align:center;padding:40px">
<h2>&#x2705; Authorization successful!</h2>
<p>You can close this window and return to your terminal/chat.</p>
</body></html>
""")
else:
error = params.get("error", ["unknown"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(f"<html><body><h2>&#x274C; Error: {error}</h2></body></html>".encode())
def log_message(self, format, *args):
pass # Suppress server logs
def register(mcp: FastMCP, strava) -> None:
@mcp.tool()
async def get_new_oauth_token(ctx: Context) -> list[TextContent]:
"""
Start the interactive Strava OAuth2 authorization flow.
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")]
redirect_uri = "http://localhost:8765/callback"
scopes = "profile:read_all,activity:read_all,activity:read,profile:write"
auth_url = (
f"https://www.strava.com/oauth/authorize"
f"?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&approval_prompt=force"
f"&scope={scopes}"
)
await ctx.info("Opening browser for Strava Authorization...")
webbrowser.open(auth_url)
await ctx.info("Waiting for you to log in and authorize (Browser opened on your computer)...")
server = HTTPServer(("localhost", 8765), CallbackHandler)
# 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.")]
await ctx.info("Authorization code received. Exchanging for tokens...")
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")
# Update the .env file if it exists
env_msg = ""
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:
for line in lines:
if line.startswith("STRAVA_REFRESH_TOKEN="):
f.write(f"STRAVA_REFRESH_TOKEN={refresh_token}\n")
else:
f.write(line)
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}"
return [TextContent(type="text", text=f"""
✅ Authorization successful!
You have successfully authenticated with Strava.
Your new Refresh Token is: `{refresh_token}`
{env_msg}
""")]
+81
View File
@@ -0,0 +1,81 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def list_athlete_clubs():
"""
List clubs that the authenticated athlete belongs to.
Returns club name, sport type, member count, and city.
"""
try:
clubs = await strava.get_athlete_clubs()
return [
{
"id": c.get("id"),
"name": c.get("name"),
"sport_type": c.get("sport_type"),
"city": c.get("city"),
"country": c.get("country"),
"member_count": c.get("member_count"),
"private": c.get("private", False),
}
for c in clubs
]
except Exception as e:
return f"Error fetching clubs: {str(e)}"
@mcp.tool()
async def get_club(club_id: int):
"""
Get detailed information about a specific Strava club.
:param club_id: The numeric ID of the club.
"""
try:
return await strava.get_club(club_id)
except Exception as e:
return f"Error fetching club: {str(e)}"
@mcp.tool()
async def get_club_activities(club_id: int, limit: int = 30):
"""
List recent activities uploaded by members of a specific club.
:param club_id: The numeric ID of the club.
:param limit: Number of activities to return (default 30).
"""
try:
activities = await strava.get_club_activities(club_id, per_page=limit)
return [
{
"name": a.get("name"),
"sport_type": a.get("sport_type") or a.get("type"),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"athlete": f"{a.get('athlete', {}).get('firstname')} {a.get('athlete', {}).get('lastname')}",
}
for a in activities
]
except Exception as e:
return f"Error fetching club activities: {str(e)}"
@mcp.tool()
async def get_club_members(club_id: int, limit: int = 30):
"""
List members of a specific Strava club.
:param club_id: The numeric ID of the club.
:param limit: Number of members to return (default 30).
"""
try:
members = await strava.get_club_members(club_id, per_page=limit)
return [
{
"id": m.get("id"),
"name": f"{m.get('firstname')} {m.get('lastname')}",
"city": m.get("city"),
"country": m.get("country"),
}
for m in members
]
except Exception as e:
return f"Error fetching club members: {str(e)}"
+28
View File
@@ -0,0 +1,28 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def get_gear_by_id(gear_id: str):
"""
Get details for a specific piece of gear (bike or shoes) by its ID.
:param gear_id: The gear ID string (e.g. 'b12345' for bikes, 'g12345' for shoes).
The gear_id can be found in activity details under the 'gear_id' field.
Returns brand, model, name, distance logged, and whether it is the primary gear.
"""
try:
g = await strava.get_gear_by_id(gear_id)
return {
"id": g.get("id"),
"name": g.get("name"),
"nickname": g.get("nickname"),
"brand_name": g.get("brand_name"),
"model_name": g.get("model_name"),
"description": g.get("description") or "",
"type": g.get("gear_type") or g.get("frame_type"),
"distance": f"{g.get('distance', 0) / 1000:.0f} km",
"primary": g.get("primary", False),
"retired": g.get("retired", False),
}
except Exception as e:
return f"Error fetching gear: {str(e)}"
+47
View File
@@ -0,0 +1,47 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.prompt()
def analyze_activity(activity_id: str) -> str:
"""
Prompt to trigger a deep analysis of a specific Strava activity.
:param activity_id: The numeric ID of the activity to analyze.
Fetches full details and produces a structured performance report.
"""
return (
f"Please analyze my Strava activity with ID {activity_id} in detail.\n\n"
"Use the get_activity_details tool to fetch the full data, then provide:\n"
"1. **Summary**: sport type, date, distance, duration, elevation\n"
"2. **Performance**: average and max heart rate, average power (if available), "
"pace or speed, cadence\n"
"3. **Segment highlights**: best segment efforts with PR or KOM ranks\n"
"4. **Gear**: which bike or shoes was used\n"
"5. **Takeaways**: 23 concrete observations about the effort "
"(e.g. strong finish, high elevation, good power-to-HR ratio)\n\n"
"Format the response clearly with headings."
)
@mcp.prompt()
def training_summary(weeks: int = 4) -> str:
"""
Prompt to generate a training load summary for the last N weeks.
:param weeks: Number of weeks to cover (default 4).
Fetches recent activities and athlete stats to produce a summary report.
"""
import time
after_ts = int(time.time()) - weeks * 7 * 24 * 3600
return (
f"Please summarize my Strava training for the last {weeks} weeks.\n\n"
f"Use list_activities with after={after_ts} (Unix timestamp = {weeks} weeks ago) "
"and a high limit to fetch all recent activities. "
"Also read the get_athlete_stats tool for overall totals.\n\n"
"Structure the report as follows:\n"
"1. **Overview**: total distance, time, elevation, and number of activities\n"
"2. **By sport**: breakdown for rides, runs, and swims separately\n"
"3. **Highlights**: longest activity, highest elevation, best average speed/pace\n"
"4. **Trend**: how does this period compare to the year-to-date stats?\n"
"5. **Recommendations**: 12 observations based on the data "
"(e.g. volume trend, missing recovery days, sport mix)\n\n"
"Format with headings and use km, hours, and meters as units."
)
+73
View File
@@ -0,0 +1,73 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def get_routes_by_athlete_id(limit: int = 30):
"""
List routes created by the authenticated Strava athlete.
:param limit: Number of routes to return (default 30, max 200).
Returns route name, type, distance, elevation gain, and estimated move time.
"""
try:
routes = await strava.get_routes_by_athlete(per_page=min(limit, 200))
return [
{
"id": str(r.get("id")),
"name": r.get("name"),
"description": r.get("description") or "",
"type": "Run" if r.get("type") == 1 else "Ride" if r.get("type") == 2 else "Other",
"sub_type": r.get("sub_type"),
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
"estimated_moving_time": f"{r.get('estimated_moving_time', 0) / 60:.0f} min",
"starred": r.get("starred", False),
"private": r.get("private", False),
}
for r in routes
]
except Exception as e:
return f"Error fetching routes: {str(e)}"
@mcp.tool()
async def get_route_by_id(route_id: str):
"""
Get detailed information about a specific Strava route by its ID.
:param route_id: The ID of the route (use the string ID from get_routes_by_athlete_id).
Returns full route details including segments.
"""
try:
r = await strava.get_route_by_id(route_id)
return {
"id": str(r.get("id")),
"name": r.get("name"),
"description": r.get("description") or "",
"type": "Run" if r.get("type") == 1 else "Ride" if r.get("type") == 2 else "Other",
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
"estimated_moving_time": f"{r.get('estimated_moving_time', 0) / 60:.0f} min",
"starred": r.get("starred", False),
"private": r.get("private", False),
"segments": [
{
"id": str(s.get("id")),
"name": s.get("name"),
"distance": f"{s.get('distance', 0) / 1000:.2f} km",
"average_grade": f"{s.get('average_grade', 0):.1f}%",
}
for s in r.get("segments", [])
],
}
except Exception as e:
return f"Error fetching route: {str(e)}"
@mcp.tool()
async def get_route_streams(route_id: str):
"""
Get data streams (distance, altitude, latlng, etc.) for a specific route.
:param route_id: The ID of the route (use the string ID from get_routes_by_athlete_id).
"""
try:
return await strava.get_route_streams(route_id)
except Exception as e:
return f"Error fetching route streams: {str(e)}"
@@ -0,0 +1,83 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def get_segment_effort(effort_id: str):
"""
Get details for a specific segment effort by its ID. Requires Strava subscription.
:param effort_id: The effort ID string — copy it exactly from get_activity_details
(under 'segment_efforts[].id'). Do NOT retype the number manually.
Returns elapsed time, moving time, power, heart rate, PR rank, and KOM rank.
"""
try:
e = await strava.get_segment_effort(effort_id)
return {
"id": str(e.get("id")),
"name": e.get("name"),
"elapsed_time": f"{e.get('elapsed_time', 0) / 60:.1f} min",
"moving_time": f"{e.get('moving_time', 0) / 60:.1f} min",
"start_date": e.get("start_date"),
"distance": f"{e.get('distance', 0) / 1000:.2f} km",
"average_watts": e.get("average_watts"),
"average_heartrate": e.get("average_heartrate"),
"max_heartrate": e.get("max_heartrate"),
"pr_rank": e.get("pr_rank"),
"kom_rank": e.get("kom_rank"),
}
except Exception as e:
return f"Error fetching segment effort: {str(e)}"
@mcp.tool()
async def list_segment_efforts(
segment_id: int,
start_date_local: str | None = None,
end_date_local: str | None = None,
limit: int = 30,
):
"""
List the authenticated athlete's efforts on a specific segment. Requires Strava subscription.
:param segment_id: The numeric ID of the segment.
:param start_date_local: Optional filter start date (ISO 8601, e.g. '2024-01-01T00:00:00Z').
:param end_date_local: Optional filter end date (ISO 8601, e.g. '2024-12-31T23:59:59Z').
:param limit: Number of efforts to return (default 30).
"""
try:
efforts = await strava.list_segment_efforts(
segment_id,
start_date_local=start_date_local,
end_date_local=end_date_local,
per_page=limit,
)
return [
{
"id": str(e.get("id")),
"elapsed_time": f"{e.get('elapsed_time', 0) / 60:.1f} min",
"moving_time": f"{e.get('moving_time', 0) / 60:.1f} min",
"start_date": e.get("start_date"),
"average_watts": e.get("average_watts"),
"average_heartrate": e.get("average_heartrate"),
"pr_rank": e.get("pr_rank"),
}
for e in efforts
]
except Exception as e:
return f"Error fetching segment efforts: {str(e)}"
@mcp.tool()
async def get_segment_effort_streams(
effort_id: str,
keys: str = "time,distance,heartrate,altitude,velocity_smooth,cadence,watts",
):
"""
Get raw data streams for a specific segment effort. Requires Strava subscription.
:param effort_id: The effort ID string — copy it exactly from get_activity_details
(under 'segment_efforts[].id'). Do NOT retype the number manually.
:param keys: Comma-separated stream types. Available: time, distance, latlng,
altitude, velocity_smooth, heartrate, cadence, watts, moving, grade_smooth.
"""
try:
key_list = [k.strip() for k in keys.split(",") if k.strip()]
return await strava.get_segment_effort_streams(effort_id, key_list)
except Exception as e:
return f"Error fetching segment effort streams: {str(e)}"
+99
View File
@@ -0,0 +1,99 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def get_segment(segment_id: int):
"""
Get detailed information about a specific Strava segment.
:param segment_id: The numeric ID of the segment.
Returns segment name, distance, grade, elevation, and effort counts.
"""
try:
s = await strava.get_segment(segment_id)
return {
"id": s.get("id"),
"name": s.get("name"),
"activity_type": s.get("activity_type"),
"distance": f"{s.get('distance', 0) / 1000:.2f} km",
"average_grade": f"{s.get('average_grade', 0):.1f}%",
"maximum_grade": f"{s.get('maximum_grade', 0):.1f}%",
"elevation_high": f"{s.get('elevation_high', 0):.0f} m",
"elevation_low": f"{s.get('elevation_low', 0):.0f} m",
"total_elevation_gain": f"{s.get('total_elevation_gain', 0):.0f} m",
"effort_count": s.get("effort_count"),
"athlete_count": s.get("athlete_count"),
"kom": s.get("xoms", {}).get("kom") or s.get("xoms", {}).get("qom"),
"starred": s.get("starred", False),
"city": s.get("city"),
"country": s.get("country"),
}
except Exception as e:
return f"Error fetching segment: {str(e)}"
@mcp.tool()
async def list_starred_segments(limit: int = 30):
"""
List segments starred by the authenticated athlete.
:param limit: Number of segments to return (default 30).
"""
try:
segments = await strava.get_starred_segments(per_page=limit)
return [
{
"id": s.get("id"),
"name": s.get("name"),
"activity_type": s.get("activity_type"),
"distance": f"{s.get('distance', 0) / 1000:.2f} km",
"average_grade": f"{s.get('average_grade', 0):.1f}%",
"effort_count": s.get("effort_count"),
"athlete_count": s.get("athlete_count"),
}
for s in segments
]
except Exception as e:
return f"Error fetching starred segments: {str(e)}"
@mcp.tool()
async def explore_segments(
bounds: str,
activity_type: str = "riding",
):
"""
Explore popular segments within a bounding box.
:param bounds: Comma-separated lat/lng bounding box: 'min_lat,min_lng,max_lat,max_lng'.
Example: '47.5,8.4,48.0,9.0' for a region near Stuttgart.
:param activity_type: Either 'riding' (default) or 'running'.
Returns top segments in the area with distance, grade, and elevation.
"""
try:
result = await strava.explore_segments(bounds, activity_type)
return [
{
"id": s.get("id"),
"name": s.get("name"),
"distance": f"{s.get('distance', 0) / 1000:.2f} km",
"average_grade": f"{s.get('avg_grade', 0):.1f}%",
"elevation_difference": f"{s.get('elev_difference', 0):.0f} m",
"climb_category": s.get("climb_category"),
}
for s in result.get("segments", [])
]
except Exception as e:
return f"Error exploring segments: {str(e)}"
@mcp.tool()
async def get_segment_streams(
segment_id: int,
keys: str = "distance,altitude,latlng,grade_smooth",
):
"""
Get raw data streams for a specific Strava segment.
:param segment_id: The numeric ID of the segment.
:param keys: Comma-separated stream types. Available: distance, altitude, latlng, grade_smooth.
"""
try:
key_list = [k.strip() for k in keys.split(",") if k.strip()]
return await strava.get_segment_streams(segment_id, key_list)
except Exception as e:
return f"Error fetching segment streams: {str(e)}"