Files
intervalsicu-mcp/src/intervals_mcp_server/tools/activities.py
T
Chris Farhood 65585c53b5 feat(activities): add search + best-efforts + interval-stats tools
search_activities queries by name/keyword; get_activity_best_efforts returns
peak values over windows for a stream; get_activity_interval_stats computes
aggregate metrics for an arbitrary stream index range (distinct from the
per-interval get_activity_intervals). Spec-required params enforced (q, stream,
start/end index). Formatters added; tools registered in server.py and __init__.

Implements #3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 15:46:35 -04:00

522 lines
18 KiB
Python

"""
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)