Files
strava-mcp-server/src/strava_mcp_server/tools/activities.py
T

356 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import (
parse_iso_to_unix,
format_date_iso,
format_date_human,
)
def _resource(uri: str, data) -> EmbeddedResource:
"""Helper: return an assistant-facing EmbeddedResource with application/json."""
return EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri=uri,
mimeType="application/json",
text=json.dumps(data, indent=2),
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
"""Helper: return a user-facing TextContent."""
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def list_activities(
ctx: Context,
limit: int = 10,
page: int = 1,
before: str | None = None,
after: str | None = None,
):
"""
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: ISO 8601 date string (e.g., '2024-05-01T00:00:00Z') — only return activities before this time.
:param after: ISO 8601 date string (e.g., '2024-04-01T00:00:00Z') — only return activities after this time.
"""
try:
await ctx.info(f"Fetching activities (Page {page}, Limit {limit})...")
activities = await strava.list_activities(
limit=min(limit, 200),
page=page,
before=parse_iso_to_unix(before),
after=parse_iso_to_unix(after),
)
essential_data = []
for a in activities:
start_date_raw = a.get("start_date")
essential_data.append(
{
"id": a["id"],
"name": a["name"],
"sport_type": a.get("sport_type") or a.get("type"),
"start_date": format_date_iso(start_date_raw),
"start_date_local": format_date_human(start_date_raw),
"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_local']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n"
return [
_user_text(markdown_summary.strip()),
_resource("internal://activities/list", essential_data),
]
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"])
name = activity.get("name", "N/A")
sport = activity.get("sport_type") or activity.get("type", "N/A")
date = format_date_human(activity.get("start_date"))
dist = f"{activity.get('distance', 0) / 1000:.2f} km"
time = f"{activity.get('moving_time', 0) / 60:.1f} min"
elev = f"{activity.get('total_elevation_gain', 0):.0f} m"
avg_hr = (
f"{activity.get('average_heartrate', 0):.0f} bpm"
if activity.get("average_heartrate")
else "N/A"
)
max_hr = (
f"{activity.get('max_heartrate', 0):.0f} bpm"
if activity.get("max_heartrate")
else "N/A"
)
avg_spd = (
f"{activity.get('average_speed', 0) * 3.6:.1f} km/h"
if activity.get("average_speed")
else "N/A"
)
avg_w = (
f"{activity.get('average_watts', 0):.0f} W"
if activity.get("average_watts")
else "N/A"
)
gear = activity.get("gear_id") or "N/A"
n_efforts = len(activity.get("segment_efforts", []))
markdown_summary = f"""### 🏃 Aktivität: {name}
| Feld | Wert |
|------|------|
| Sport | {sport} |
| Datum | {date} |
| Distanz | {dist} |
| Zeit | {time} |
| Höhenmeter | {elev} |
| Ø Herzfrequenz | {avg_hr} |
| Max Herzfrequenz | {max_hr} |
| Ø Geschwindigkeit | {avg_spd} |
| Ø Leistung | {avg_w} |
| Ausrüstung | {gear} |
| Segment-Efforts | {n_efforts} |"""
return [
_user_text(markdown_summary.strip()),
_resource(f"internal://activities/{activity_id}", activity),
]
except Exception as e:
return [
TextContent(
type="text", text=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)
data = [
{
"id": c.get("id"),
"text": c.get("text"),
"athlete": f"{c.get('athlete', {}).get('firstname')} {c.get('athlete', {}).get('lastname')}",
"created_at": format_date_iso(c.get("created_at")),
}
for c in comments
]
if not data:
md = "### 💬 Keine Kommentare vorhanden."
else:
md = f"### 💬 Kommentare ({len(data)})\n"
for c in data:
md += f"- **{c['athlete']}** ({format_date_human(c['created_at'])}): {c['text']}\n"
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/comments", data),
]
except Exception as e:
return [TextContent(type="text", text=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)
data = [
{
"id": k.get("id"),
"name": f"{k.get('firstname')} {k.get('lastname')}",
"city": k.get("city"),
"country": k.get("country"),
}
for k in kudoers
]
if not data:
md = "### 👍 Noch keine Kudos."
else:
md = f"### 👍 Kudos ({len(data)})\n"
md += "| Name | Ort |\n|------|-----|\n"
for k in data:
loc = ", ".join(filter(None, [k["city"], k["country"]])) or "N/A"
md += f"| {k['name']} | {loc} |\n"
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/kudoers", data),
]
except Exception as e:
return [TextContent(type="text", text=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)
data = [
{
"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
]
if not data:
md = "### 🔄 Keine Runden gefunden."
else:
md = f"### 🔄 Runden ({len(data)})\n"
md += "| # | Distanz | Zeit | Ø Speed | Ø HR |\n"
md += "|---|---------|------|---------|------|\n"
for lap in data:
hr = (
f"{lap['average_heartrate']:.0f} bpm"
if lap["average_heartrate"]
else "-"
)
md += f"| {lap['lap_index']} | {lap['distance']} | {lap['moving_time']} | {lap['average_speed']} | {hr} |\n"
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/laps", data),
]
except Exception as e:
return [TextContent(type="text", text=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)
data = []
md = "### 💓 Zonen-Verteilung\n\n"
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", []))
]
zone_data = {
"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,
}
data.append(zone_data)
label = (
"Herzfrequenz"
if zone_data["type"] == "heartrate"
else "Leistung (Power)"
)
md += f"#### {label}\n| Zone | Bereich | Zeit |\n|------|---------|------|\n"
for b in buckets:
max_val = "max" if b["max"] == -1 else str(b["max"])
md += f"| {b['zone']} | {b['min']} {max_val} | {b['time_in_zone']} |\n"
md += "\n"
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/zones", data),
]
except Exception as e:
return [
TextContent(
type="text", text=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)}"