""" Activity-related MCP tools for Intervals.icu. This module contains tools for retrieving and managing athlete activities. """ from datetime import datetime, timedelta from typing import Any from intervals_mcp_server import credentials from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.credentials import CredentialError from intervals_mcp_server.tools.gear import ( resolve_gear_for_activity, resolve_gear_for_activities, ) from intervals_mcp_server.utils.formatting import ( format_activity_message, format_activity_search_results, format_activity_summary, format_best_efforts, format_interval_stats, format_intervals, ) from intervals_mcp_server.utils.validation import resolve_date_params # Import mcp instance from shared module for tool registration from intervals_mcp_server.mcp_instance import mcp # noqa: F401 def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]: """Extract a list of activity dictionaries from the API result.""" activities: list[dict[str, Any]] = [] if isinstance(result, list): activities = [item for item in result if isinstance(item, dict)] elif isinstance(result, dict): # Result is a single activity or a container for _key, value in result.items(): if isinstance(value, list): activities = [item for item in value if isinstance(item, dict)] break # If no list was found but the dict has typical activity fields, treat it as a single activity if not activities and any(key in result for key in ["name", "startTime", "distance"]): activities = [result] return activities def _filter_named_activities(activities: list[dict[str, Any]]) -> list[dict[str, Any]]: """Filter out unnamed activities from the list.""" return [ activity for activity in activities if activity.get("name") and activity.get("name") != "Unnamed" ] async def _fetch_more_activities( athlete_id: str, start_date: str, api_key: str | None, api_limit: int, ) -> list[dict[str, Any]]: """Fetch additional activities from an earlier date range.""" oldest_date = datetime.fromisoformat(start_date) older_start_date = (oldest_date - timedelta(days=60)).strftime("%Y-%m-%d") older_end_date = (oldest_date - timedelta(days=1)).strftime("%Y-%m-%d") if older_start_date >= older_end_date: return [] more_params = { "oldest": older_start_date, "newest": older_end_date, "limit": api_limit, } more_result = await make_intervals_request( url=f"/athlete/{athlete_id}/activities", api_key=api_key, params=more_params, ) if isinstance(more_result, list): return _filter_named_activities(more_result) return [] def _format_activities_response( activities: list[dict[str, Any]], athlete_id: str, include_unnamed: bool, ) -> str: """Format the activities response based on the results.""" if not activities: if include_unnamed: return ( f"No valid activities found for athlete {athlete_id} in the specified date range." ) return f"No named activities found for athlete {athlete_id} in the specified date range. Try with include_unnamed=True to see all activities." # Format the output activities_summary = "Activities:\n\n" for activity in activities: if isinstance(activity, dict): activities_summary += format_activity_summary(activity) + "\n" else: activities_summary += f"Invalid activity format: {activity}\n\n" return activities_summary @mcp.tool() async def get_activities( # pylint: disable=too-many-return-statements,too-many-branches start_date: str | None = None, end_date: str | None = None, limit: int = 10, include_unnamed: bool = False, ) -> str: """Get a list of activities for the signed-in athlete from Intervals.icu Args: start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) end_date: End date in YYYY-MM-DD format (optional, defaults to today) limit: Maximum number of activities to return (optional, defaults to 10) include_unnamed: Whether to include unnamed activities (optional, defaults to False) """ try: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) start_date, end_date = resolve_date_params(start_date, end_date) # Fetch more activities if we need to filter out unnamed ones api_limit = limit * 3 if not include_unnamed else limit # Call the Intervals.icu API params = {"oldest": start_date, "newest": end_date, "limit": api_limit} result = await make_intervals_request( url=f"/athlete/{athlete_id_to_use}/activities", api_key=api_key, params=params ) # Check for error if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error fetching activities: {error_message}" if not result: return f"No activities found for athlete {athlete_id_to_use} in the specified date range." # Parse activities from result activities = _parse_activities_from_result(result) if not activities: return f"No valid activities found for athlete {athlete_id_to_use} in the specified date range." # Filter and fetch more if needed if not include_unnamed: activities = _filter_named_activities(activities) # If we don't have enough named activities, try to fetch more if len(activities) < limit: more_activities = await _fetch_more_activities( athlete_id_to_use, start_date, api_key, api_limit ) activities.extend(more_activities) # Limit to requested count activities = activities[:limit] # Resolve gear names (in-place injection of `_resolved_gear_name`) await resolve_gear_for_activities( activities, athlete_id=athlete_id_to_use, api_key=api_key ) return _format_activities_response(activities, athlete_id_to_use, include_unnamed) @mcp.tool() async def get_activity_details(activity_id: str) -> str: """Get detailed information for a specific activity from Intervals.icu Args: activity_id: The Intervals.icu activity ID """ try: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) # Call the Intervals.icu API result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key) if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error fetching activity details: {error_message}" # Format the response if not result: return f"No details found for activity {activity_id}." # If result is a list, use the first item if available activity_data = result[0] if isinstance(result, list) and result else result if not isinstance(activity_data, dict): return f"Invalid activity format for activity {activity_id}." # Resolve gear name for the signed-in athlete await resolve_gear_for_activity(activity_data, athlete_id=athlete_id_to_use, api_key=api_key) # Return a more detailed view of the activity detailed_view = format_activity_summary(activity_data) # Add additional details if available if "zones" in activity_data: zones = activity_data["zones"] detailed_view += "\nPower Zones:\n" for zone in zones.get("power", []): detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n" detailed_view += "\nHeart Rate Zones:\n" for zone in zones.get("hr", []): detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n" return detailed_view @mcp.tool() async def get_activity_intervals(activity_id: str) -> str: """Get interval data for a specific activity from Intervals.icu This endpoint returns detailed metrics for each interval in an activity, including power, heart rate, cadence, speed, and environmental data. It also includes grouped intervals if applicable. Args: activity_id: The Intervals.icu activity ID """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) # Call the Intervals.icu API result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key) if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error fetching intervals: {error_message}" # Format the response if not result: return f"No interval data found for activity {activity_id}." # If the result is empty or doesn't contain expected fields if not isinstance(result, dict) or not any( key in result for key in ["icu_intervals", "icu_groups"] ): return f"No interval data or unrecognized format for activity {activity_id}." # Format the intervals data return format_intervals(result) @mcp.tool() async def get_activity_streams( activity_id: str, stream_types: str | None = None, ) -> str: """Get stream data for a specific activity from Intervals.icu This endpoint returns time-series data for an activity, including metrics like power, heart rate, cadence, altitude, distance, temperature, and velocity data. Args: activity_id: The Intervals.icu activity ID stream_types: Comma-separated list of stream types to retrieve (optional, defaults to all available types) Available types: time, watts, heartrate, cadence, altitude, distance, core_temperature, skin_temperature, velocity_smooth """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) # Build query parameters params = {} if stream_types: params["types"] = stream_types else: # Default to common stream types if none specified params["types"] = "time,watts,heartrate,cadence,altitude,distance,velocity_smooth" # Call the Intervals.icu API result = await make_intervals_request( url=f"/activity/{activity_id}/streams", api_key=api_key, params=params, ) if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error fetching activity streams: {error_message}" # Format the response if not result: return f"No stream data found for activity {activity_id}." # Ensure result is a list streams = result if isinstance(result, list) else [] if not streams: return f"No stream data found for activity {activity_id}." # Format the streams data streams_summary = f"Activity Streams for {activity_id}:\n\n" for stream in streams: if not isinstance(stream, dict): continue stream_type = stream.get("type", "unknown") stream_name = stream.get("name", stream_type) data = stream.get("data", []) value_type = stream.get("valueType", "") streams_summary += f"Stream: {stream_name} ({stream_type})\n" streams_summary += f" Value Type: {value_type}\n" streams_summary += f" Data Points: {len(data)}\n" # Show first few and last few data points for preview if data: if len(data) <= 10: streams_summary += f" Values: {data}\n" else: preview_start = data[:5] preview_end = data[-5:] streams_summary += f" First 5 values: {preview_start}\n" streams_summary += f" Last 5 values: {preview_end}\n" streams_summary += "\n" return streams_summary @mcp.tool() async def get_activity_messages(activity_id: str) -> str: """Get messages (notes/comments) for a specific activity from Intervals.icu Args: activity_id: The Intervals.icu activity ID """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) result = await make_intervals_request( url=f"/activity/{activity_id}/messages", api_key=api_key, ) if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error fetching activity messages: {error_message}" if not result: return f"No messages found for activity {activity_id}." messages = result if isinstance(result, list) else [] if not messages: return f"No messages found for activity {activity_id}." output = f"Messages for activity {activity_id}:\n\n" for msg in messages: if isinstance(msg, dict): output += format_activity_message(msg) + "\n\n" return output @mcp.tool() async def add_activity_message( activity_id: str, content: str, ) -> str: """Add a message (note/comment) to an activity on Intervals.icu Args: activity_id: The Intervals.icu activity ID content: The message text to add """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) result = await make_intervals_request( url=f"/activity/{activity_id}/messages", api_key=api_key, method="POST", data={"content": content}, ) if isinstance(result, dict) and "error" in result: error_message = result.get("message", "Unknown error") return f"Error adding message to activity: {error_message}" if not result or not isinstance(result, dict): return "Error: Unexpected response when adding message." msg_id = result.get("id") if msg_id is not None: return f"Successfully added message (ID: {msg_id}) to activity {activity_id}." return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually." @mcp.tool() async def search_activities(query: str, limit: int = 20) -> str: """Search the athlete's activities by name/keyword. Args: query: Search text matched against activity name/description (required). limit: Maximum number of results to return (default 20). """ try: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) if not query or not query.strip(): return "Error: a non-empty search query is required." params: dict[str, Any] = {"q": query.strip(), "limit": limit} result = await make_intervals_request( url=f"/athlete/{athlete_id_to_use}/activities/search", api_key=api_key, params=params ) if isinstance(result, dict) and "error" in result: return f"Error searching activities: {result.get('message')}" results = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else [] if not results: return f"No activities found matching '{query}'." return format_activity_search_results(results) @mcp.tool() async def get_activity_best_efforts( activity_id: str, stream: str = "watts", duration: int | None = None, distance: float | None = None, count: int | None = None, ) -> str: """Get the best efforts (peak values over windows) for an activity. Args: activity_id: The Intervals.icu activity ID. stream: Data stream to analyze — e.g. "watts", "heartrate", "pace" (default "watts"). duration: Optional window duration in seconds to target. distance: Optional window distance in meters to target. count: Optional maximum number of efforts to return. """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) params: dict[str, Any] = {"stream": stream} if duration is not None: params["duration"] = duration if distance is not None: params["distance"] = distance if count is not None: params["count"] = count result = await make_intervals_request( url=f"/activity/{activity_id}/best-efforts", api_key=api_key, params=params ) if isinstance(result, dict) and "error" in result: return f"Error fetching best efforts: {result.get('message')}" efforts = result.get("efforts") if isinstance(result, dict) else None if not efforts: return f"No best-effort data found for activity {activity_id} (stream: {stream})." return format_best_efforts([e for e in efforts if isinstance(e, dict)], stream) @mcp.tool() async def get_activity_interval_stats(activity_id: str, start_index: int, end_index: int) -> str: """Compute aggregate stats for an index range of an activity's data streams. start_index/end_index are positions in the activity's streams (as seen in the streams or interval output). This computes metrics for that slice — it does NOT list the activity's own intervals (use get_activity_intervals for that). Args: activity_id: The Intervals.icu activity ID. start_index: Start position in the activity streams (required). end_index: End position in the activity streams (required, > start_index). """ try: _athlete_id, api_key = await credentials.resolve_caller_credentials() except CredentialError as exc: return str(exc) if start_index < 0 or end_index <= start_index: return "Error: end_index must be greater than start_index and both non-negative." params: dict[str, Any] = {"start_index": start_index, "end_index": end_index} result = await make_intervals_request( url=f"/activity/{activity_id}/interval-stats", api_key=api_key, params=params ) if isinstance(result, dict) and "error" in result: return f"Error fetching interval stats: {result.get('message')}" if not isinstance(result, dict) or not result: return f"No interval stats found for activity {activity_id} ({start_index}-{end_index})." return format_interval_stats(result)