Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s
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:
@@ -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}."
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user