feat(wellness): add update_wellness write tool, computed Form (TSB), date-label fix
Add an update_wellness MCP tool that writes nutrition macros, hydration, vitals,
sleep, and subjective ratings to Intervals.icu via PUT /athlete/{id}/wellness/{date}.
Only provided fields are sent; pass -1 to clear a numeric field and locked=True to
stop device/app syncs from overwriting the values. Sleep is taken in hours and
stored as seconds (with -1 passing through as the clear sentinel).
Also surface computed Form (TSB = CTL - ATL) in wellness output, and prefer an
explicit `date` field over the record `id` for the Date label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
This commit is contained in:
@@ -86,7 +86,10 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
|
||||
get_events,
|
||||
)
|
||||
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
|
||||
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402
|
||||
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||
get_wellness_data,
|
||||
update_wellness,
|
||||
)
|
||||
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
|
||||
create_custom_item,
|
||||
@@ -113,6 +116,8 @@ __all__ = [
|
||||
"delete_events_by_date_range",
|
||||
"add_or_update_event",
|
||||
"get_wellness_data",
|
||||
"update_wellness",
|
||||
"get_gear_list",
|
||||
"get_athlete_power_curves",
|
||||
"get_custom_items",
|
||||
"get_custom_item_by_id",
|
||||
|
||||
@@ -32,7 +32,10 @@ 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
|
||||
from intervals_mcp_server.tools.wellness import ( # noqa: F401
|
||||
get_wellness_data,
|
||||
update_wellness,
|
||||
)
|
||||
|
||||
|
||||
def register_tools(mcp_instance: FastMCP) -> None:
|
||||
@@ -70,4 +73,5 @@ __all__ = [
|
||||
"get_athlete_power_curves",
|
||||
"get_gear_list",
|
||||
"get_wellness_data",
|
||||
"update_wellness",
|
||||
]
|
||||
|
||||
@@ -4,11 +4,14 @@ Wellness-related MCP tools for Intervals.icu.
|
||||
This module contains tools for retrieving athlete wellness data.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from intervals_mcp_server import credentials
|
||||
from intervals_mcp_server.api.client import make_intervals_request
|
||||
from intervals_mcp_server.credentials import CredentialError
|
||||
from intervals_mcp_server.utils.formatting import format_wellness_entry
|
||||
from intervals_mcp_server.utils.validation import resolve_date_params
|
||||
from intervals_mcp_server.utils.validation import resolve_date_params, validate_date
|
||||
|
||||
# Import mcp instance from shared module for tool registration
|
||||
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||
@@ -65,3 +68,118 @@ async def get_wellness_data(
|
||||
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
|
||||
|
||||
return wellness_summary
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def update_wellness( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
date: str | None = None,
|
||||
weight: float | None = None,
|
||||
resting_hr: int | None = None,
|
||||
hrv: float | None = None,
|
||||
sleep_hours: float | None = None,
|
||||
sleep_quality: int | None = None,
|
||||
calories_consumed: int | None = None,
|
||||
carbohydrates: float | None = None,
|
||||
protein: float | None = None,
|
||||
fat: float | None = None,
|
||||
hydration_volume: float | None = None,
|
||||
hydration_score: int | None = None,
|
||||
soreness: int | None = None,
|
||||
fatigue: int | None = None,
|
||||
stress: int | None = None,
|
||||
mood: int | None = None,
|
||||
motivation: int | None = None,
|
||||
injury: int | None = None,
|
||||
comments: str | None = None,
|
||||
locked: bool | None = None,
|
||||
) -> str:
|
||||
"""Create or update the signed-in athlete's wellness record for a single day.
|
||||
|
||||
Writes nutrition, hydration, vitals, sleep, and subjective ratings to
|
||||
Intervals.icu (PUT /athlete/{id}/wellness/{date}). Only the fields you pass are
|
||||
sent; anything omitted is left untouched. To CLEAR an existing numeric value,
|
||||
pass ``-1``. Set ``locked=True`` to stop Intervals.icu from overwriting these
|
||||
values on its next sync from a connected device or app.
|
||||
|
||||
Args:
|
||||
date: Day to update in YYYY-MM-DD format (optional, defaults to today).
|
||||
weight: Body weight in kg.
|
||||
resting_hr: Resting heart rate in bpm.
|
||||
hrv: Heart rate variability (rMSSD).
|
||||
sleep_hours: Sleep duration in hours (stored by Intervals.icu as seconds).
|
||||
Pass -1 to clear.
|
||||
sleep_quality: Sleep quality rating, as used in the Intervals.icu app.
|
||||
calories_consumed: Energy intake in kcal.
|
||||
carbohydrates: Carbohydrate intake in grams.
|
||||
protein: Protein intake in grams.
|
||||
fat: Fat intake in grams.
|
||||
hydration_volume: Fluid intake volume, in your Intervals.icu units.
|
||||
hydration_score: Subjective hydration score, as used in the app.
|
||||
soreness: Subjective soreness rating, as used in the Intervals.icu app.
|
||||
fatigue: Subjective fatigue rating, as used in the Intervals.icu app.
|
||||
stress: Subjective stress rating, as used in the Intervals.icu app.
|
||||
mood: Subjective mood rating, as used in the Intervals.icu app.
|
||||
motivation: Subjective motivation rating, as used in the Intervals.icu app.
|
||||
injury: Injury level rating, as used in the Intervals.icu app.
|
||||
comments: Free-text note for the day.
|
||||
locked: If True, lock the record so device/app syncs won't overwrite it.
|
||||
"""
|
||||
try:
|
||||
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||
except CredentialError as exc:
|
||||
return str(exc)
|
||||
|
||||
if not date:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
try:
|
||||
date = validate_date(date)
|
||||
except ValueError as exc:
|
||||
return f"Error: {exc}"
|
||||
|
||||
# Sleep is passed in hours but stored as seconds; -1 is the clear sentinel and
|
||||
# must pass through unscaled.
|
||||
sleep_secs: int | None = None
|
||||
if sleep_hours is not None:
|
||||
sleep_secs = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
|
||||
|
||||
# Map snake_case tool params to the Intervals.icu camelCase wellness fields.
|
||||
field_map: list[tuple[str, Any]] = [
|
||||
("weight", weight),
|
||||
("restingHR", resting_hr),
|
||||
("hrv", hrv),
|
||||
("sleepSecs", sleep_secs),
|
||||
("sleepQuality", sleep_quality),
|
||||
("kcalConsumed", calories_consumed),
|
||||
("carbohydrates", carbohydrates),
|
||||
("protein", protein),
|
||||
("fatTotal", fat),
|
||||
("hydrationVolume", hydration_volume),
|
||||
("hydration", hydration_score),
|
||||
("soreness", soreness),
|
||||
("fatigue", fatigue),
|
||||
("stress", stress),
|
||||
("mood", mood),
|
||||
("motivation", motivation),
|
||||
("injury", injury),
|
||||
("comments", comments),
|
||||
("locked", locked),
|
||||
]
|
||||
payload: dict[str, Any] = {k: v for k, v in field_map if v is not None}
|
||||
|
||||
if not payload:
|
||||
return "No wellness fields provided. Pass at least one field to update."
|
||||
|
||||
result = await make_intervals_request(
|
||||
url=f"/athlete/{athlete_id_to_use}/wellness/{date}",
|
||||
api_key=api_key,
|
||||
method="PUT",
|
||||
data=payload,
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return f"Error updating wellness data: {result.get('message')}"
|
||||
|
||||
# Intervals.icu echoes back the full updated record; render it for confirmation.
|
||||
if isinstance(result, dict):
|
||||
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
|
||||
return f"Updated wellness for {date}."
|
||||
|
||||
@@ -161,6 +161,18 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
|
||||
for k, label in [
|
||||
("ctl", "Fitness (CTL)"),
|
||||
("atl", "Fatigue (ATL)"),
|
||||
]:
|
||||
if entries.get(k) is not None:
|
||||
training_metrics.append(f"- {label}: {entries[k]}")
|
||||
|
||||
# Form (a.k.a. TSB, Training Stress Balance) = CTL - ATL. Intervals.icu does
|
||||
# not return this on the wellness record, so compute it when both components
|
||||
# are present. Positive = fresher/tapered, negative = carrying fatigue.
|
||||
ctl, atl = entries.get("ctl"), entries.get("atl")
|
||||
if ctl is not None and atl is not None:
|
||||
training_metrics.append(f"- Form (TSB): {ctl - atl:.1f}")
|
||||
|
||||
for k, label in [
|
||||
("rampRate", "Ramp Rate"),
|
||||
("ctlLoad", "CTL Load"),
|
||||
("atlLoad", "ATL Load"),
|
||||
@@ -337,7 +349,9 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
|
||||
entries.get("tempRestingHR")
|
||||
|
||||
lines = ["Wellness Data:"]
|
||||
lines.append(f"Date: {entries.get('id', 'N/A')}")
|
||||
# The wellness record's own date lives in `id` (e.g. "2025-05-24"); some call
|
||||
# sites also inject an explicit `date`. Prefer `date`, fall back to `id`.
|
||||
lines.append(f"Date: {entries.get('date', entries.get('id', 'N/A'))}")
|
||||
lines.append("")
|
||||
|
||||
training_metrics = _format_training_metrics(entries)
|
||||
|
||||
Reference in New Issue
Block a user