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,
)
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",
+5 -1
View File
@@ -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",
]
+119 -1
View File
@@ -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}."
+15 -1
View File
@@ -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)
@@ -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
+18
View File
@@ -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.
+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."