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,
|
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",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user