feat(workouts): add workout library read tools (get_workouts, get_workout)
get_workouts lists the reusable library (client-side folder/sport filters); get_workout renders a single workout including its structured workout_doc steps via a defensive, depth-capped recursive formatter (repeats, ramps, warmup/ cooldown, power/hr/pace targets). New tools/workouts.py; registered. Implements #4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
This commit is contained in:
@@ -98,6 +98,10 @@ from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import
|
||||
get_athlete_summary,
|
||||
get_sport_settings,
|
||||
)
|
||||
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||
get_workout,
|
||||
get_workouts,
|
||||
)
|
||||
|
||||
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
|
||||
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||
@@ -132,6 +136,8 @@ __all__ = [
|
||||
"get_athlete_profile",
|
||||
"get_sport_settings",
|
||||
"get_athlete_summary",
|
||||
"get_workouts",
|
||||
"get_workout",
|
||||
"get_gear_list",
|
||||
"get_athlete_power_curves",
|
||||
"get_custom_items",
|
||||
|
||||
@@ -44,6 +44,7 @@ from intervals_mcp_server.tools.athlete import ( # noqa: F401
|
||||
get_athlete_summary,
|
||||
get_sport_settings,
|
||||
)
|
||||
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
|
||||
|
||||
|
||||
def register_tools(mcp_instance: FastMCP) -> None:
|
||||
@@ -88,4 +89,6 @@ __all__ = [
|
||||
"get_athlete_profile",
|
||||
"get_sport_settings",
|
||||
"get_athlete_summary",
|
||||
"get_workouts",
|
||||
"get_workout",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Workout-library MCP tools for Intervals.icu.
|
||||
|
||||
Read tools exposing the athlete's reusable workout library (distinct from the
|
||||
calendar *events* handled in tools/events.py).
|
||||
"""
|
||||
|
||||
from intervals_mcp_server import credentials
|
||||
from intervals_mcp_server.api.client import make_intervals_request
|
||||
from intervals_mcp_server.credentials import CredentialError
|
||||
from intervals_mcp_server.utils.formatting import format_workout_details, format_workout_summary
|
||||
|
||||
# Import mcp instance from shared module for tool registration
|
||||
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_workouts(folder_id: int | None = None, sport_type: str | None = None) -> str:
|
||||
"""List the athlete's reusable workout library.
|
||||
|
||||
Filtering is applied client-side (the API returns the full library).
|
||||
|
||||
Args:
|
||||
folder_id: Optional folder ID to restrict results to one folder.
|
||||
sport_type: Optional sport type to filter by (e.g. "Ride", "Run").
|
||||
"""
|
||||
try:
|
||||
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||
except CredentialError as exc:
|
||||
return str(exc)
|
||||
|
||||
result = await make_intervals_request(url=f"/athlete/{athlete_id}/workouts", api_key=api_key)
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return f"Error fetching workouts: {result.get('message')}"
|
||||
|
||||
workouts = [w for w in result if isinstance(w, dict)] if isinstance(result, list) else []
|
||||
if folder_id is not None:
|
||||
workouts = [w for w in workouts if w.get("folder_id") == folder_id]
|
||||
if sport_type:
|
||||
want = sport_type.strip().lower()
|
||||
workouts = [w for w in workouts if str(w.get("type", "")).lower() == want]
|
||||
|
||||
if not workouts:
|
||||
return "No workouts found."
|
||||
|
||||
lines = [f"Workout Library ({len(workouts)}):", ""]
|
||||
lines.extend(format_workout_summary(w) for w in workouts)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_workout(workout_id: int) -> str:
|
||||
"""Get a single library workout's full structure (steps and targets).
|
||||
|
||||
Args:
|
||||
workout_id: The Intervals.icu workout ID.
|
||||
"""
|
||||
try:
|
||||
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||
except CredentialError as exc:
|
||||
return str(exc)
|
||||
|
||||
result = await make_intervals_request(
|
||||
url=f"/athlete/{athlete_id}/workouts/{workout_id}", api_key=api_key
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return f"Error fetching workout: {result.get('message')}"
|
||||
if not isinstance(result, dict) or not result:
|
||||
return f"No workout found with ID {workout_id}."
|
||||
return format_workout_details(result)
|
||||
@@ -878,3 +878,101 @@ def format_interval_stats(interval: dict[str, Any]) -> str:
|
||||
if interval.get(key) is not None:
|
||||
lines.append(f"- {label}: {interval[key]}{(' ' + unit) if unit else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_step_intensity(step: dict[str, Any]) -> str:
|
||||
"""Render a workout step's intensity target(s) from raw workout_doc JSON."""
|
||||
bits = []
|
||||
for key, label in [("power", ""), ("hr", "HR"), ("pace", "Pace"), ("cadence", "Cad")]:
|
||||
v = step.get(key)
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
units = v.get("units", "")
|
||||
if v.get("start") is not None and v.get("end") is not None:
|
||||
val = f"{v['start']}-{v['end']}"
|
||||
elif v.get("value") is not None:
|
||||
val = f"{v['value']}"
|
||||
else:
|
||||
continue
|
||||
bits.append(f"{(label + ' ') if label else ''}{val}{units}")
|
||||
return ", ".join(bits)
|
||||
|
||||
|
||||
def _format_workout_step(step: dict[str, Any], depth: int = 0) -> list[str]:
|
||||
"""Recursively render one workout_doc step (handles repeat blocks). Depth-capped."""
|
||||
indent = " " * (depth + 1)
|
||||
if depth > 6:
|
||||
return [f"{indent}- ...(nested too deep)"]
|
||||
reps = step.get("reps")
|
||||
substeps = step.get("steps")
|
||||
if reps and isinstance(substeps, list):
|
||||
lines = [f"{indent}{reps}x:"]
|
||||
for sub in substeps:
|
||||
if isinstance(sub, dict):
|
||||
lines.extend(_format_workout_step(sub, depth + 1))
|
||||
return lines
|
||||
parts = []
|
||||
if step.get("duration") is not None:
|
||||
parts.append(_format_duration_label(int(step["duration"])))
|
||||
if step.get("distance") is not None:
|
||||
parts.append(f"{step['distance']}m")
|
||||
tag = ""
|
||||
if step.get("warmup"):
|
||||
tag = " (warmup)"
|
||||
elif step.get("cooldown"):
|
||||
tag = " (cooldown)"
|
||||
elif step.get("freeride"):
|
||||
tag = " (free ride)"
|
||||
intensity = _format_step_intensity(step)
|
||||
if step.get("ramp") and intensity:
|
||||
intensity = "ramp " + intensity
|
||||
label = " ".join(parts) if parts else "step"
|
||||
detail = f" @ {intensity}" if intensity else ""
|
||||
text = step.get("text")
|
||||
return [f"{indent}- {label}{detail}{tag}{(' — ' + text) if text else ''}"]
|
||||
|
||||
|
||||
def format_workout_summary(workout: dict[str, Any]) -> str:
|
||||
"""Format one library workout as a compact one-line list entry."""
|
||||
line = " | ".join([str(workout.get("name", "Unnamed")), str(workout.get("type", "?"))])
|
||||
extra = []
|
||||
if workout.get("icu_training_load") is not None:
|
||||
extra.append(f"load {workout['icu_training_load']}")
|
||||
if workout.get("moving_time") is not None:
|
||||
extra.append(f"{workout['moving_time']}s")
|
||||
if workout.get("folder_id") is not None:
|
||||
extra.append(f"folder {workout['folder_id']}")
|
||||
if extra:
|
||||
line += " (" + ", ".join(extra) + ")"
|
||||
return line + f" [id: {workout.get('id', 'N/A')}]"
|
||||
|
||||
|
||||
def format_workout_details(workout: dict[str, Any]) -> str:
|
||||
"""Format a library workout in full, including its structured steps."""
|
||||
lines = [f"Workout: {workout.get('name', 'Unnamed')}", f"ID: {workout.get('id', 'N/A')}"]
|
||||
for key, label, unit in [
|
||||
("type", "Type", ""),
|
||||
("sub_type", "Sub-type", ""),
|
||||
("indoor", "Indoor", ""),
|
||||
("moving_time", "Duration", "s"),
|
||||
("distance", "Distance", "m"),
|
||||
("icu_training_load", "Training Load", ""),
|
||||
("icu_intensity", "Intensity", ""),
|
||||
("carbs_per_hour", "Carbs", "g/hr"),
|
||||
("folder_id", "Folder", ""),
|
||||
]:
|
||||
if workout.get(key) is not None:
|
||||
lines.append(f"{label}: {workout[key]}{(' ' + unit) if unit else ''}")
|
||||
if workout.get("description"):
|
||||
lines.append(f"Description: {workout['description']}")
|
||||
tags = workout.get("tags")
|
||||
if isinstance(tags, list) and tags:
|
||||
lines.append("Tags: " + ", ".join(str(t) for t in tags))
|
||||
|
||||
doc = workout.get("workout_doc")
|
||||
if isinstance(doc, dict) and isinstance(doc.get("steps"), list) and doc["steps"]:
|
||||
lines += ["", "Steps:"]
|
||||
for step in doc["steps"]:
|
||||
if isinstance(step, dict):
|
||||
lines.extend(_format_workout_step(step))
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user