Initial commit: Modularized Strava MCP Server with UV and Hatchling
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Strava MCP Server"""
|
||||
__version__ = "0.1.0"
|
||||
@@ -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>✅ 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>❌ 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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)}"
|
||||
@@ -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)}"
|
||||
@@ -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>✅ 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>❌ 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}
|
||||
""")]
|
||||
@@ -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)}"
|
||||
@@ -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)}"
|
||||
@@ -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**: 2–3 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**: 1–2 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."
|
||||
)
|
||||
@@ -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)}"
|
||||
@@ -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)}"
|
||||
Reference in New Issue
Block a user