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:
2026-07-20 09:28:31 -04:00
parent 74fb09972a
commit f2159d3ca5
7 changed files with 292 additions and 4 deletions
+6 -1
View File
@@ -86,7 +86,10 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
get_events, get_events,
) )
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402 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.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 from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item, create_custom_item,
@@ -113,6 +116,8 @@ __all__ = [
"delete_events_by_date_range", "delete_events_by_date_range",
"add_or_update_event", "add_or_update_event",
"get_wellness_data", "get_wellness_data",
"update_wellness",
"get_gear_list",
"get_athlete_power_curves", "get_athlete_power_curves",
"get_custom_items", "get_custom_items",
"get_custom_item_by_id", "get_custom_item_by_id",
+5 -1
View File
@@ -32,7 +32,10 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves, get_athlete_power_curves,
) )
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401 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: def register_tools(mcp_instance: FastMCP) -> None:
@@ -70,4 +73,5 @@ __all__ = [
"get_athlete_power_curves", "get_athlete_power_curves",
"get_gear_list", "get_gear_list",
"get_wellness_data", "get_wellness_data",
"update_wellness",
] ]
+119 -1
View File
@@ -4,11 +4,14 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data. 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 import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.credentials import CredentialError from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_wellness_entry 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 # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 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" wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary 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}."
+15 -1
View File
@@ -161,6 +161,18 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
for k, label in [ for k, label in [
("ctl", "Fitness (CTL)"), ("ctl", "Fitness (CTL)"),
("atl", "Fatigue (ATL)"), ("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"), ("rampRate", "Ramp Rate"),
("ctlLoad", "CTL Load"), ("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"), ("atlLoad", "ATL Load"),
@@ -337,7 +349,9 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
entries.get("tempRestingHR") entries.get("tempRestingHR")
lines = ["Wellness Data:"] 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("") lines.append("")
training_metrics = _format_training_metrics(entries) training_metrics = _format_training_metrics(entries)
@@ -4,6 +4,7 @@ Date: 2025-05-24
Training Metrics: Training Metrics:
- Fitness (CTL): 70.87253 - Fitness (CTL): 70.87253
- Fatigue (ATL): 91.97159 - Fatigue (ATL): 91.97159
- Form (TSB): -21.1
- Ramp Rate: 6.997368 - Ramp Rate: 6.997368
- CTL Load: 299 - CTL Load: 299
- ATL Load: 299 - ATL Load: 299
+18
View File
@@ -64,6 +64,24 @@ def test_format_wellness_entry():
assert result == expected_result 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(): def test_format_wellness_entry_include_all_fields():
""" """
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields. Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
+128
View File
@@ -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."