Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s

- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching
  current Claude clients; the old 2025-06-18 server never got a tools/list on
  the connector surface).
- Bake transport config into code: stateless_http + json_response for HTTP
  (single JSON body instead of a 34KB SSE stream, which the connector pipeline
  handles far more reliably).
- Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth,
  configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the
  runtime FastMCP.__init__ monkeypatch from the k8s deployment command.
- Accept token audience with/without trailing slash (RFC 8707 clients use the
  slash-normalised resource metadata value).
- Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to
  build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:07:46 -04:00
commit 935abf86d4
46 changed files with 8485 additions and 0 deletions
@@ -0,0 +1,73 @@
"""
MCP tools registry for Intervals.icu MCP Server.
This module registers all available MCP tools with the FastMCP server instance.
"""
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
# Import all tools for re-export
# Note: Tools register themselves via @mcp.tool() decorators when imported
from intervals_mcp_server.tools.activities import ( # noqa: F401
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_streams,
)
from intervals_mcp_server.tools.events import ( # noqa: F401
add_or_update_event,
delete_event,
delete_events_by_date_range,
get_event_by_id,
get_events,
)
from intervals_mcp_server.tools.custom_items import ( # noqa: F401
create_custom_item,
delete_custom_item,
get_custom_item_by_id,
get_custom_items,
update_custom_item,
)
from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves,
)
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401
def register_tools(mcp_instance: FastMCP) -> None:
"""
Register all MCP tools with the FastMCP server instance.
This function imports all tool modules, which causes their @mcp.tool()
decorators to register the tools. The tools need access to the mcp instance,
so they will be imported after the mcp instance is created.
Args:
mcp_instance (FastMCP): The FastMCP server instance to register tools with.
"""
# Tools are registered via decorators when modules are imported above
# The mcp_instance parameter is kept for future use if needed
_ = mcp_instance
__all__ = [
"register_tools",
"get_activities",
"get_activity_details",
"get_activity_intervals",
"get_activity_streams",
"get_events",
"get_event_by_id",
"delete_event",
"delete_events_by_date_range",
"add_or_update_event",
"get_custom_items",
"get_custom_item_by_id",
"create_custom_item",
"update_custom_item",
"delete_custom_item",
"get_athlete_power_curves",
"get_gear_list",
"get_wellness_data",
]
@@ -0,0 +1,394 @@
"""
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.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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_summary, format_intervals
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
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-arguments,too-many-return-statements,too-many-branches,too-many-positional-arguments
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
limit: int = 10,
include_unnamed: bool = False,
) -> str:
"""Get a list of activities for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
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)
"""
# Resolve athlete ID and date parameters
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
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, api_key: str | None = None) -> str:
"""Get detailed information for a specific activity from Intervals.icu
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# 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 (uses configured athlete_id via ATHLETE_ID env var)
await resolve_gear_for_activity(activity_data, 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, api_key: str | None = None) -> 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
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# 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,
api_key: str | None = None,
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
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
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
"""
# 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, api_key: str | None = None) -> str:
"""Get messages (notes/comments) for a specific activity from Intervals.icu
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
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,
api_key: str | None = None,
) -> 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
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
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."
@@ -0,0 +1,232 @@
"""
Custom items MCP tools for Intervals.icu.
This module contains tools for managing athlete custom items (charts, fields, zones, etc.).
"""
import json
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_custom_item_details
from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool()
async def get_custom_items(
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom items: {result.get('message')}"
if not result:
return f"No custom items found for athlete {athlete_id_to_use}."
output = "Custom Items:\n\n"
for item in result:
if isinstance(item, dict):
output += f"- ID: {item.get('id')}\n"
output += f" Name: {item.get('name', 'N/A')}\n"
output += f" Type: {item.get('type', 'N/A')}\n"
if item.get("description"):
output += f" Description: {item['description']}\n"
output += "\n"
return output
@mcp.tool()
async def get_custom_item_by_id(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get detailed information for a specific custom item from Intervals.icu
Args:
item_id: The custom item ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return f"No custom item found with ID {item_id}."
return format_custom_item_details(result)
@mcp.tool()
async def create_custom_item(
name: str,
item_type: str,
athlete_id: str | None = None,
api_key: str | None = None,
description: str | None = None,
content: dict[str, Any] | None = None,
visibility: str | None = None,
) -> str:
"""Create a new custom item for an athlete on Intervals.icu
Args:
name: Name of the custom item
item_type: Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES)
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
description: Description of the custom item (optional)
content: Configuration content for the custom item as a dict (optional). Important enum values:
- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
data: dict[str, Any] = {"name": name, "type": item_type}
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item",
api_key=api_key,
data=data,
method="POST",
)
if isinstance(result, dict) and "error" in result:
return f"Error creating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when creating custom item."
return f"Successfully created custom item:\n\n{format_custom_item_details(result)}"
@mcp.tool()
async def update_custom_item(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
name: str | None = None,
item_type: str | None = None,
description: str | None = None,
content: dict[str, Any] | None = None,
visibility: str | None = None,
) -> str:
"""Update an existing custom item for an athlete on Intervals.icu
Args:
item_id: The custom item ID to update
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
name: New name for the custom item (optional)
item_type: New type for the custom item (optional)
description: New description for the custom item (optional)
content: New configuration content for the custom item as a dict (optional). Important enum values:
- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
data: dict[str, Any] = {}
if name is not None:
data["name"] = name
if item_type is not None:
data["type"] = item_type
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
data=data,
method="PUT",
)
if isinstance(result, dict) and "error" in result:
return f"Error updating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when updating custom item."
return f"Successfully updated custom item:\n\n{format_custom_item_details(result)}"
@mcp.tool()
async def delete_custom_item(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete a custom item for an athlete from Intervals.icu
Args:
item_id: The custom item ID to delete
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting custom item: {result.get('message')}"
return f"Successfully deleted custom item {item_id}."
+433
View File
@@ -0,0 +1,433 @@
"""
Event-related MCP tools for Intervals.icu.
This module contains tools for retrieving, creating, updating, and deleting athlete events.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.dates import get_default_end_date, get_default_future_end_date
from intervals_mcp_server.utils.formatting import format_event_details, format_event_summary
from intervals_mcp_server.utils.types import WorkoutDoc
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id, validate_date
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
def _prepare_event_data( # pylint: disable=too-many-arguments,too-many-positional-arguments
name: str,
workout_type: str,
start_date: str,
workout_doc: WorkoutDoc | None,
moving_time: int | None,
distance: int | None,
) -> dict[str, Any]:
"""Prepare event data dictionary for API request.
Many arguments are required to match the Intervals.icu API event structure.
"""
resolved_workout_type = resolve_activity_type(name, workout_type)
return {
"start_date_local": start_date + "T00:00:00",
"category": "WORKOUT",
"name": name,
"description": str(workout_doc) if workout_doc else None,
"type": resolved_workout_type,
"moving_time": moving_time,
"distance": distance,
}
def _handle_event_response(
result: dict[str, Any] | list[dict[str, Any]] | None,
action: str,
athlete_id: str,
start_date: str,
) -> str:
"""Handle API response and format appropriate message."""
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error {action} event: {error_message}"
if not result:
return f"No events {action} for athlete {athlete_id}."
if isinstance(result, dict):
return f"Successfully {action} event id: {result.get('id')}"
return f"Event {action} successfully at {start_date}"
async def _delete_events_list(
athlete_id: str, api_key: str | None, events: list[dict[str, Any]]
) -> list[int | str | None]:
"""Delete a list of events and return IDs of failed deletions.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
events: List of event dictionaries to delete.
Returns:
List of event IDs that failed to delete.
"""
failed_events: list[int | str | None] = []
for event in events:
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events/{event.get('id')}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
failed_events.append(event.get("id"))
return failed_events
@mcp.tool()
async def get_events(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""Get events for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today)
"""
# Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
# Parse date parameters (events use different defaults)
if not start_date:
start_date = get_default_end_date()
if not end_date:
end_date = get_default_future_end_date()
# Call the Intervals.icu API
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events", 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 events: {error_message}"
# Format the response
if not result:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
# Ensure result is a list
events = result if isinstance(result, list) else []
if not events:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
events_summary = "Events:\n\n"
for event in events:
if not isinstance(event, dict):
continue
events_summary += format_event_summary(event) + "\n\n"
return events_summary
@mcp.tool()
async def get_event_by_id(
event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get detailed information for a specific event from Intervals.icu
Args:
event_id: The Intervals.icu event ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
# Call the Intervals.icu API
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/event/{event_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching event details: {error_message}"
# Format the response
if not result:
return f"No details found for event {event_id}."
if not isinstance(result, dict):
return f"Invalid event format for event {event_id}."
return format_event_details(result)
@mcp.tool()
async def delete_event(
event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete event for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not event_id:
return "Error: No event ID provided."
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events/{event_id}", api_key=api_key, method="DELETE"
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting event: {result.get('message')}"
return json.dumps(result, indent=2)
async def _fetch_events_for_deletion(
athlete_id: str, api_key: str | None, start_date: str, end_date: str
) -> tuple[list[dict[str, Any]], str | None]:
"""Fetch events for deletion and return them with any error message.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
Tuple of (events_list, error_message). error_message is None if successful.
"""
params = {"oldest": validate_date(start_date), "newest": validate_date(end_date)}
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return [], f"Error deleting events: {result.get('message')}"
events = result if isinstance(result, list) else []
return events, None
@mcp.tool()
async def delete_events_by_date_range(
start_date: str,
end_date: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete events for an athlete from Intervals.icu in the specified date range.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
events, error_msg = await _fetch_events_for_deletion(
athlete_id_to_use, api_key, start_date, end_date
)
if error_msg:
return error_msg
failed_events = await _delete_events_list(athlete_id_to_use, api_key, events)
deleted_count = len(events) - len(failed_events)
return f"Deleted {deleted_count} events. Failed to delete {len(failed_events)} events: {failed_events}"
@mcp.tool()
async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-positional-arguments
workout_type: str,
name: str,
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None,
start_date: str | None = None,
workout_doc: WorkoutDoc | None = None,
moving_time: int | None = None,
distance: int | None = None,
) -> str:
"""Post event for an athlete to Intervals.icu this follows the event api from intervals.icu
If event_id is provided, the event will be updated instead of created.
Many arguments are required as this MCP tool function maps directly to the Intervals.icu API parameters.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID (optional, will use event_id from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
name: Name of the activity
workout_doc: steps as a list of Step objects (optional, but necessary to define workout steps)
workout_type: Workout type (e.g. Ride, Run, Swim, Walk, Row)
moving_time: Total expected moving time of the workout in seconds (optional)
distance: Total expected distance of the workout in meters (optional)
Example:
"workout_doc": {
"description": "High-intensity workout for increasing VO2 max",
"steps": [
{"power": {"value": 80, "units": "%ftp"}, "duration": 900, "warmup": true},
{"reps": 2, "text": "High-intensity intervals", "steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]},
{"power": {"value": 80, "units": "%ftp"}, "duration": 600, "cooldown": true},
{"text": ""}
]
}
Step properties:
distance: Distance of step in meters
{"distance": 5000}
duration: Duration of step in seconds
{"duration": 1800}
power/hr/pace/cadence: Define step intensity
Percentage of FTP: {"power": {"value": 80, "units": "%ftp"}}
Absolute power: {"power": {"value": 200, "units": "w"}}
Heart rate: {"hr": {"value": 75, "units": "%hr"}}
Heart rate (LTHR): {"hr": {"value": 85, "units": "%lthr"}}
Cadence: {"cadence": {"value": 90, "units": "cadence"}}
Pace by ftp: {"pace": {"value": 80, "units": "%pace"}}
Pace by zone: {"pace": {"value": 2, "units": "pace_zone"}}
Zone by power: {"power": {"value": 2, "units": "power_zone"}}
Zone by heart rate: {"hr": {"value": 2, "units": "hr_zone"}}
Ranges: Specify ranges for power, heart rate, or cadence:
{"power": {"start": 80, "end": 90, "units": "%ftp"}}
Ramps: Instead of a range, indicate a gradual change in intensity (useful for ERG workouts):
{"ramp": true, "power": {"start": 80, "end": 90, "units": "%ftp"}}
Repeats: include the reps property and add nested steps
{"reps": 3,
"steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]}
Free Ride: Include freeride to indicate a segment without ERG control, optionally with a suggested power range:
{"freeride": true, "power": {"value": 80, "units": "%ftp"}}
Comments and Labels: Add descriptive text to label steps:
{"text": "Warmup"}
How to use steps:
- Set distance or duration as appropriate for step
- Use "reps" with nested steps to define repeat intervals (as in example above)
- Define one of "power", "hr" or "pace" to define step intensity
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = _prepare_event_data(
name, workout_type, validated_date, workout_doc, moving_time, distance
)
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
@mcp.tool()
async def add_or_update_note(
name: str,
description: str,
start_date: str | None = None,
color: str | None = "green",
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None,
) -> str:
"""Add or update a plain text note (category NOTE) on the Intervals.icu calendar.
Args:
name: Title of the note
description: Plain text content of the note
start_date: Date in YYYY-MM-DD format (optional, defaults to today)
color: Color of the note (e.g. green, orange, red, blue)
athlete_id: The Intervals.icu athlete ID (optional)
api_key: The Intervals.icu API key (optional)
event_id: The Intervals.icu event ID (optional, for updates)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = {
"category": "NOTE",
"name": name,
"description": description,
"start_date_local": validated_date + "T00:00:00",
"color": color
}
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
async def _create_or_update_event_request(
athlete_id: str,
api_key: str | None,
event_data: dict[str, Any],
start_date: str,
event_id: str | None,
) -> str:
"""Create or update an event via API request.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
event_data: Prepared event data dictionary.
start_date: Start date string for response formatting.
event_id: Optional event ID for updates.
Returns:
Formatted response string.
"""
url = f"/athlete/{athlete_id}/events"
if event_id:
url += f"/{event_id}"
result = await make_intervals_request(
url=url,
api_key=api_key,
data=event_data,
method="PUT" if event_id else "POST",
)
action = "updated" if event_id else "created"
return _handle_event_response(result, action, athlete_id, start_date)
+198
View File
@@ -0,0 +1,198 @@
"""
Gear-related MCP tools for Intervals.icu.
This module provides:
- A module-level cache of the athlete's raw gear catalog (bikes, shoes, etc.)
to avoid hitting the /athlete/{id}/gear endpoint on every activity lookup.
- A helper to inject the human-readable gear name into an activity dict (under
`_resolved_gear_name`), which the formatter then displays in the `Gear:` block.
- A user-facing MCP tool `get_gear_list` so the assistant can discover or
refresh the gear catalog on demand.
Intervals.icu's activity payload includes only the gear ID (e.g. `b16177481`)
but not the gear name. The gear name lives in a separate endpoint
`/athlete/{athlete_id}/gear` that returns the full catalog. To avoid an extra
round-trip per activity, we cache the raw gear catalog per athlete for the
lifetime of the MCP server process and derive the `{id: name}` lookup from it.
Call `get_gear_list(refresh=True)` to bust the cache.
"""
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
# Module-level cache of the raw gear catalog per athlete. Single source of
# truth: the id->name map and the rich listing are both derived from this.
_GEAR_RAW_CACHE: dict[str, list[dict[str, Any]]] = {}
def _extract_gear_id(activity: dict[str, Any]) -> str | None:
"""Pull the gear ID out of an activity dict, handling the two known shapes."""
gear_raw = activity.get("gear")
if isinstance(gear_raw, dict):
gear_id = gear_raw.get("id")
if gear_id:
return str(gear_id)
gear_id = activity.get("gear_id")
if gear_id:
return str(gear_id)
return None
def _items_from_response(result: Any) -> list[dict[str, Any]]:
"""Normalize the /athlete/{id}/gear response into a list of gear dicts."""
if isinstance(result, list):
return [item for item in result if isinstance(item, dict)]
if isinstance(result, dict):
# Some endpoints wrap the list in a container; pull any list value.
for value in result.values():
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _derive_gear_map(items: list[dict[str, Any]]) -> dict[str, str]:
"""Convert a raw gear list into a {gear_id: gear_name} lookup."""
gear_map: dict[str, str] = {}
for item in items:
gid = item.get("id")
name = item.get("name") or item.get("display_name")
if gid and name:
gear_map[str(gid)] = str(name)
return gear_map
async def get_gear_raw(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> list[dict[str, Any]]:
"""Return (and cache) the raw gear list for an athlete.
Single source of truth that backs both the id->name map and the rich
listing produced by `get_gear_list`. One API call per athlete per process
lifetime unless `refresh=True`.
Args:
athlete_id: Athlete to look up. Defaults to ATHLETE_ID env var via config.
api_key: Override the configured API key.
refresh: If True, ignore the cache and re-fetch from the API.
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg or not athlete_id_to_use:
return []
if not refresh and athlete_id_to_use in _GEAR_RAW_CACHE:
return _GEAR_RAW_CACHE[athlete_id_to_use]
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/gear", api_key=api_key
)
items = _items_from_response(result)
_GEAR_RAW_CACHE[athlete_id_to_use] = items
return items
async def get_gear_map(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> dict[str, str]:
"""Return the {gear_id: gear_name} lookup for an athlete (derived from cache)."""
items = await get_gear_raw(athlete_id=athlete_id, api_key=api_key, refresh=refresh)
return _derive_gear_map(items)
async def resolve_gear_for_activity(
activity: dict[str, Any],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into an activity dict if gear info is present.
Mutates the activity dict in place. Safe to call when gear is absent (no-op).
Uses the cached gear map; the first call per athlete triggers a fetch.
"""
gear_id = _extract_gear_id(activity)
if not gear_id:
return
gear_map = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
name = gear_map.get(gear_id)
if name:
activity["_resolved_gear_name"] = name
async def resolve_gear_for_activities(
activities: list[dict[str, Any]],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into each activity in a list. In-place."""
if not activities:
return
# Pre-warm the cache once, then iterate.
_ = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
for activity in activities:
if isinstance(activity, dict):
await resolve_gear_for_activity(
activity, athlete_id=athlete_id, api_key=api_key
)
@mcp.tool()
async def get_gear_list(
athlete_id: str | None = None,
api_key: str | None = None,
refresh: bool = False,
) -> str:
"""Get the gear catalog (bikes, shoes, etc.) for an athlete from Intervals.icu.
Returns one line per gear item with id, type, name, and basic stats.
The result is cached for the MCP process lifetime; pass refresh=True to
re-fetch.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
refresh: If True, bypass the cache and re-fetch from the API (default False)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not athlete_id_to_use:
return "Error: athlete_id is required (either as argument or via ATHLETE_ID env var)."
# Single fetch path: get_gear_raw consults the cache and only hits the API
# on a cold cache or when refresh=True.
items = await get_gear_raw(
athlete_id=athlete_id_to_use, api_key=api_key, refresh=refresh
)
if not items:
return f"No gear found for athlete {athlete_id_to_use}."
output = f"Gear catalog for athlete {athlete_id_to_use}:\n\n"
output += f"{'ID':<14} {'Type':<8} {'Name':<32} {'Default':<8} {'Acts':<6} {'Dist (km)':<10} {'Retired':<8}\n"
output += f"{'-' * 14} {'-' * 8} {'-' * 32} {'-' * 8} {'-' * 6} {'-' * 10} {'-' * 8}\n"
for it in items:
gid = str(it.get("id", "?"))
gtype = str(it.get("component_type", it.get("type", "?")))
name = str(it.get("name", "?"))[:32]
default_for = it.get("default_for_type") or it.get("default_for") or ""
acts = str(it.get("activities", it.get("activity_count", "?")))
dist_m = it.get("distance", 0) or 0
dist_km = f"{dist_m / 1000:.1f}" if isinstance(dist_m, (int, float)) else "?"
retired = "yes" if it.get("retired") else ""
output += f"{gid:<14} {gtype:<8} {name:<32} {str(default_for):<8} {acts:<6} {dist_km:<10} {retired:<8}\n"
return output
@@ -0,0 +1,214 @@
"""
Power curve MCP tools for Intervals.icu.
This module contains tools for retrieving athlete power curve data.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_power_curves
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
# 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min
DEFAULT_DURATIONS: tuple[int, ...] = (5, 15, 30, 60, 120, 300, 600, 1200, 3600)
def _build_curves_param(
this_season: bool,
last_season: bool,
start_date: str | None,
end_date: str | None,
) -> list[str]:
"""Build the curves query parameter list based on user selections.
Args:
this_season: Whether to include this season's curve.
last_season: Whether to include last season's curve.
start_date: Optional start date for a custom date range curve.
end_date: Optional end date for a custom date range curve.
Returns:
List of curve identifiers for the API request.
"""
curves: list[str] = []
if this_season:
curves.append("s0")
if last_season:
curves.append("s1")
if start_date and end_date:
curves.append(f"r.{start_date}.{end_date}")
return curves
def _validate_dates(start_date: str | None, end_date: str | None) -> str | None:
"""Validate that start_date and end_date are either both provided or both absent.
Returns:
An error message if validation fails, otherwise None.
"""
if (start_date is None) != (end_date is None):
return "Error: Both start_date and end_date must be provided together for a custom date range."
if start_date and end_date:
try:
s = datetime.strptime(start_date, "%Y-%m-%d")
e = datetime.strptime(end_date, "%Y-%m-%d")
if s >= e:
return "Error: start_date must be before end_date."
except ValueError:
return "Error: Dates must be in YYYY-MM-DD format."
return None
def _extract_curve_data(
curve: dict[str, Any],
durations: list[int],
include_normalised: bool,
) -> dict[str, Any]:
"""Extract power data for requested durations from a single curve.
Args:
curve: A single curve object from the API response.
durations: List of durations in seconds to extract.
include_normalised: Whether to include W/kg data.
Returns:
Dictionary with curve metadata and extracted data points.
"""
secs = curve.get("secs", [])
values = curve.get("values", [])
activity_ids = curve.get("activity_id", [])
watts_per_kg = curve.get("watts_per_kg", [])
wkg_activity_ids = curve.get("wkg_activity_id", [])
# Build a lookup from seconds to index for efficient access
sec_to_idx: dict[int, int] = {s: i for i, s in enumerate(secs)}
data_points: list[dict[str, Any]] = []
for dur in durations:
idx = sec_to_idx.get(dur)
if idx is None or idx >= len(values):
continue
point: dict[str, Any] = {
"secs": dur,
"watts": values[idx],
"activity_id": (
activity_ids[idx]
if idx < len(activity_ids) and activity_ids[idx] is not None
else ""
),
}
if include_normalised and idx < len(watts_per_kg):
point["watts_per_kg"] = round(watts_per_kg[idx], 2)
point["wkg_activity_id"] = (
wkg_activity_ids[idx]
if idx < len(wkg_activity_ids)
and wkg_activity_ids[idx] is not None
else ""
)
data_points.append(point)
return {
"id": curve.get("id", ""),
"label": curve.get("label", curve.get("id", "")),
"start": curve.get("start_date_local", ""),
"end": curve.get("end_date_local", ""),
"data_points": data_points,
}
@mcp.tool()
async def get_athlete_power_curves(
activity_type: str = "Ride",
durations: list[int] | None = None,
indoor_outdoor: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
this_season: bool = True,
last_season: bool = True,
include_normalised: bool = True,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get power curves for an athlete from Intervals.icu.
Returns best power output for selected durations across specified time periods.
Uses FFT power computation. Power values are in watts.
Args:
activity_type: Activity type (e.g. "Ride", "Run", "VirtualRide"). Default is "Ride".
durations: Durations in seconds to include. Default is [5, 15, 30, 60, 120, 300, 600, 1200, 3600]
indoor_outdoor: Filter by location — "indoor" or "outdoor". Omit for no filtering.
start_date: Start date (YYYY-MM-DD) for custom date range curve. Must be used with end_date.
end_date: End date (YYYY-MM-DD) for custom date range curve. Must be used with start_date.
this_season: Include this season's curve (default True)
last_season: Include last season's curve (default True)
include_normalised: Include weight-normalised W/kg values (default True)
athlete_id: Intervals.icu athlete ID (optional, uses ATHLETE_ID from .env if not provided)
api_key: Optional API key override. Uses API_KEY from .env if not provided.
"""
if durations is None:
durations = list(DEFAULT_DURATIONS)
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"):
return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted."
date_error = _validate_dates(start_date, end_date)
if date_error:
return date_error
curves = _build_curves_param(this_season, last_season, start_date, end_date)
if not curves:
return "Error: At least one curve must be selected (this_season, last_season, or a date range)."
params: dict[str, Any] = {
"curves": curves,
"type": activity_type,
"includeRanks": False,
}
if indoor_outdoor:
params["filters"] = json.dumps(
[{"field_id": "indoor", "value": indoor_outdoor, "id": 1}]
)
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/power-curves",
params=params,
api_key=api_key,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching power curves: {error_message}"
# Response has a "list" key containing curve objects
curve_list: list[dict[str, Any]] = []
if isinstance(result, dict):
curve_list = result.get("list", [])
elif isinstance(result, list):
curve_list = result
if not curve_list:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
extracted: list[dict[str, Any]] = []
for curve in curve_list:
if isinstance(curve, dict):
extracted.append(_extract_curve_data(curve, durations, include_normalised))
if not extracted:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
return format_power_curves(extracted, activity_type, include_normalised)
@@ -0,0 +1,71 @@
"""
Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool()
async def get_wellness_data(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
include_all_fields: bool = False,
) -> str:
"""Get wellness data for an athlete from Intervals.icu.
By default returns standard wellness fields (training metrics, vitals, sleep,
subjective scores, etc.). Set include_all_fields=True to also include any
additional or custom fields configured by the user in Intervals.icu.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
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)
include_all_fields: If True, include additional and custom fields beyond the standard set (optional, defaults to False)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
start_date, end_date = resolve_date_params(start_date, end_date)
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching wellness data: {result.get('message')}"
if not result:
return (
f"No wellness data found for athlete {athlete_id_to_use} in the specified date range."
)
wellness_summary = "Wellness Data:\n\n"
if isinstance(result, dict):
for date_str, data in result.items():
if isinstance(data, dict) and "date" not in data:
data["date"] = date_str
wellness_summary += format_wellness_entry(data, include_all_fields=include_all_fields) + "\n\n"
elif isinstance(result, list):
for entry in result:
if isinstance(entry, dict):
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary