From f2159d3ca503fad3b4c63911bd090a7303256a5b Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 20 Jul 2026 09:28:31 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN --- src/intervals_mcp_server/server.py | 7 +- src/intervals_mcp_server/tools/__init__.py | 6 +- src/intervals_mcp_server/tools/wellness.py | 120 +++++++++++++++- src/intervals_mcp_server/utils/formatting.py | 16 ++- tests/ressources/wellness_entry_formatted.txt | 1 + tests/test_formatting.py | 18 +++ tests/test_wellness.py | 128 ++++++++++++++++++ 7 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 tests/test_wellness.py diff --git a/src/intervals_mcp_server/server.py b/src/intervals_mcp_server/server.py index 074196c..5efe59a 100644 --- a/src/intervals_mcp_server/server.py +++ b/src/intervals_mcp_server/server.py @@ -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", diff --git a/src/intervals_mcp_server/tools/__init__.py b/src/intervals_mcp_server/tools/__init__.py index e9a4999..a6b3128 100644 --- a/src/intervals_mcp_server/tools/__init__.py +++ b/src/intervals_mcp_server/tools/__init__.py @@ -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", ] diff --git a/src/intervals_mcp_server/tools/wellness.py b/src/intervals_mcp_server/tools/wellness.py index df99338..e1e023f 100644 --- a/src/intervals_mcp_server/tools/wellness.py +++ b/src/intervals_mcp_server/tools/wellness.py @@ -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}." diff --git a/src/intervals_mcp_server/utils/formatting.py b/src/intervals_mcp_server/utils/formatting.py index 2e68a81..2fa6146 100644 --- a/src/intervals_mcp_server/utils/formatting.py +++ b/src/intervals_mcp_server/utils/formatting.py @@ -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) diff --git a/tests/ressources/wellness_entry_formatted.txt b/tests/ressources/wellness_entry_formatted.txt index 7570639..b709150 100644 --- a/tests/ressources/wellness_entry_formatted.txt +++ b/tests/ressources/wellness_entry_formatted.txt @@ -4,6 +4,7 @@ Date: 2025-05-24 Training Metrics: - Fitness (CTL): 70.87253 - Fatigue (ATL): 91.97159 +- Form (TSB): -21.1 - Ramp Rate: 6.997368 - CTL Load: 299 - ATL Load: 299 diff --git a/tests/test_formatting.py b/tests/test_formatting.py index ee846a7..9d92d3b 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -64,6 +64,24 @@ def test_format_wellness_entry(): assert result == expected_result +def test_format_wellness_entry_computes_form_tsb(): + """Form (TSB) is computed as CTL - ATL when both are present.""" + result = format_wellness_entry({"id": "2024-06-01", "ctl": 50, "atl": 65}) + assert "Form (TSB): -15.0" in result + + +def test_format_wellness_entry_no_form_without_both_components(): + """Form is omitted if either CTL or ATL is missing.""" + result = format_wellness_entry({"id": "2024-06-01", "ctl": 50}) + assert "Form (TSB)" not in result + + +def test_format_wellness_entry_prefers_explicit_date_over_id(): + """An explicit `date` field wins over `id` for the Date line.""" + result = format_wellness_entry({"id": "2024-06-01", "date": "2024-06-02", "ctl": 50}) + assert "Date: 2024-06-02" in result + + def test_format_wellness_entry_include_all_fields(): """ Test that format_wellness_entry with include_all_fields=True includes additional unknown fields. diff --git a/tests/test_wellness.py b/tests/test_wellness.py new file mode 100644 index 0000000..d437965 --- /dev/null +++ b/tests/test_wellness.py @@ -0,0 +1,128 @@ +""" +Tests for intervals_mcp_server.tools.wellness. + +Covers the wellness write tool (``update_wellness``): field mapping to the +Intervals.icu camelCase schema, the sleep hours->seconds conversion and the +``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and +the error / credential branches. The default caller credentials come from the +autouse fixture in conftest (athlete ``i1``). +""" + +import asyncio + +from intervals_mcp_server import credentials +from intervals_mcp_server.credentials import CredentialError +from intervals_mcp_server.tools import wellness + + +def _patch_request(monkeypatch, result): + """Patch make_intervals_request; capture the call kwargs, return ``result``.""" + calls: list[dict] = [] + + async def fake(**kwargs): + calls.append(kwargs) + return result + + monkeypatch.setattr(wellness, "make_intervals_request", fake) + return calls + + +def test_update_wellness_maps_fields_and_puts(monkeypatch): + calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78}) + out = asyncio.run( + wellness.update_wellness( + date="2025-05-24", + weight=78, + resting_hr=50, + hrv=65.5, + sleep_hours=8, + calories_consumed=2200, + carbohydrates=300, + protein=140, + fat=70, + hydration_volume=2.5, + comments="felt good", + ) + ) + + assert len(calls) == 1 + call = calls[0] + assert call["method"] == "PUT" + assert call["url"] == "/athlete/i1/wellness/2025-05-24" + assert call["api_key"] == "testkey" + + payload = call["data"] + assert payload["weight"] == 78 + assert payload["restingHR"] == 50 + assert payload["hrv"] == 65.5 + assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds + assert payload["kcalConsumed"] == 2200 + assert payload["carbohydrates"] == 300 + assert payload["protein"] == 140 + assert payload["fatTotal"] == 70 + assert payload["hydrationVolume"] == 2.5 + assert payload["comments"] == "felt good" + # Omitted fields must not be sent. + assert "mood" not in payload + assert "locked" not in payload + + assert "Updated wellness for 2025-05-24" in out + + +def test_update_wellness_defaults_date_to_today(monkeypatch): + calls = _patch_request(monkeypatch, {"id": "today"}) + asyncio.run(wellness.update_wellness(weight=80)) + # URL date segment defaults to today's date (YYYY-MM-DD, 10 chars). + date_seg = calls[0]["url"].rsplit("/", 1)[1] + assert len(date_seg) == 10 and date_seg.count("-") == 2 + + +def test_update_wellness_no_fields_returns_message(monkeypatch): + calls = _patch_request(monkeypatch, {}) + out = asyncio.run(wellness.update_wellness(date="2025-05-24")) + assert "No wellness fields provided" in out + assert calls == [] # no request made + + +def test_update_wellness_clear_with_negative_one(monkeypatch): + calls = _patch_request(monkeypatch, {"id": "2025-05-24"}) + asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1)) + payload = calls[0]["data"] + assert payload["weight"] == -1 + # -1 is the clear sentinel and must NOT be scaled to -3600. + assert payload["sleepSecs"] == -1 + + +def test_update_wellness_locked_false_is_sent(monkeypatch): + calls = _patch_request(monkeypatch, {"id": "2025-05-24"}) + asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False)) + assert calls[0]["data"]["locked"] is False + + +def test_update_wellness_error_path(monkeypatch): + _patch_request(monkeypatch, {"error": True, "message": "boom"}) + out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80)) + assert "Error updating wellness data: boom" in out + + +def test_update_wellness_invalid_date(monkeypatch): + calls = _patch_request(monkeypatch, {}) + out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80)) + assert out.startswith("Error:") + assert calls == [] + + +def test_update_wellness_credential_error(monkeypatch): + async def _deny(): + raise CredentialError("not approved") + + monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny) + out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80)) + assert "not approved" in out + + +def test_update_wellness_non_dict_result(monkeypatch): + # If the API returns a list (unexpected), we still confirm the write. + _patch_request(monkeypatch, []) + out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80)) + assert out == "Updated wellness for 2025-05-24."