Initial commit: Modularized Strava MCP Server with UV and Hatchling
This commit is contained in:
@@ -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)}"
|
||||
Reference in New Issue
Block a user