feat(writes): add update_wellness_bulk and update_sport_settings

update_wellness_bulk writes many days in one PUT to /wellness-bulk; the
snake_case->camelCase mapping is extracted into a shared _wellness_payload helper
so single and bulk can't drift, and the whole batch is rejected if any date is
invalid (no partial writes).

update_sport_settings changes FTP/LTHR/pace/zones with a dual guardrail: a warning
docstring, a native ctx.elicit() confirmation on capable clients, and a hard
confirm=True fallback that refuses the write (returning the old->new diff) on
clients without elicitation. It read-modify-writes the full record and passes the
spec-required recalcHrZones query param. Widened the HTTP client's data type to
accept the bulk array.

Implements #5.

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 15:54:10 -04:00
parent e18e05e02c
commit 9a66183d39
7 changed files with 458 additions and 30 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ async def make_intervals_request(
api_key: str | None = None,
params: dict[str, Any] | None = None,
method: str = "GET",
data: dict[str, Any] | None = None,
data: dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
"""
Make a request to the Intervals.icu API with proper error handling.
+4
View File
@@ -92,11 +92,13 @@ from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wro
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
get_wellness_data,
update_wellness,
update_wellness_bulk,
)
from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import-position # noqa: E402
get_athlete_profile,
get_athlete_summary,
get_sport_settings,
update_sport_settings,
)
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
get_workout,
@@ -133,9 +135,11 @@ __all__ = [
"add_or_update_event",
"get_wellness_data",
"update_wellness",
"update_wellness_bulk",
"get_athlete_profile",
"get_sport_settings",
"get_athlete_summary",
"update_sport_settings",
"get_workouts",
"get_workout",
"get_gear_list",
@@ -38,11 +38,13 @@ from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import ( # noqa: F401
get_wellness_data,
update_wellness,
update_wellness_bulk,
)
from intervals_mcp_server.tools.athlete import ( # noqa: F401
get_athlete_profile,
get_athlete_summary,
get_sport_settings,
update_sport_settings,
)
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
@@ -86,9 +88,11 @@ __all__ = [
"get_gear_list",
"get_wellness_data",
"update_wellness",
"update_wellness_bulk",
"get_athlete_profile",
"get_sport_settings",
"get_athlete_summary",
"update_sport_settings",
"get_workouts",
"get_workout",
]
+121
View File
@@ -8,6 +8,9 @@ coach needs to reason about intensity and readiness.
from typing import Any
from mcp.server.fastmcp import Context # pylint: disable=import-error
from pydantic import BaseModel # pylint: disable=import-error
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.credentials import CredentialError
@@ -22,6 +25,12 @@ from intervals_mcp_server.utils.validation import resolve_date_params
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
class _ConfirmThresholdChange(BaseModel):
"""Elicitation schema: the user confirms (or not) a threshold change."""
confirm: bool = False
@mcp.tool()
async def get_athlete_profile() -> str:
"""Get the signed-in athlete's profile from Intervals.icu.
@@ -127,3 +136,115 @@ async def get_athlete_summary(
header = f"Athlete Summary ({start_date} to {end_date}):\n\n"
return header + "\n\n".join(format_athlete_summary(s) for s in summaries)
@mcp.tool()
async def update_sport_settings( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements
settings_id: int,
ftp: int | None = None,
indoor_ftp: int | None = None,
w_prime: int | None = None,
lthr: int | None = None,
max_hr: int | None = None,
threshold_pace: float | None = None,
recalc_hr_zones: bool = False,
confirm: bool = False,
ctx: Context | None = None,
) -> str:
"""⚠️ Change the athlete's training thresholds (FTP, LTHR, pace) for one sport.
These values drive ALL future load, intensity and zone calculations across
Intervals.icu. Do NOT call this speculatively — show the athlete the exact
old→new values and get their explicit approval first.
This is a confirmed write. On clients that support MCP elicitation you will be
prompted to approve the change; otherwise you MUST pass ``confirm=True`` after the
athlete has agreed. Without confirmation the tool refuses and returns the diff.
Args:
settings_id: The sport-settings record ID (from get_sport_settings).
ftp: New FTP in watts.
indoor_ftp: New indoor FTP in watts.
w_prime: New W' in joules.
lthr: New lactate-threshold HR in bpm.
max_hr: New max HR in bpm.
threshold_pace: New threshold pace (in the sport's pace units).
recalc_hr_zones: If True, ask Intervals.icu to recompute HR zones from the new LTHR/max HR.
confirm: Set True to confirm the change on clients without elicitation support.
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
proposed = {
"ftp": ftp,
"indoor_ftp": indoor_ftp,
"w_prime": w_prime,
"lthr": lthr,
"max_hr": max_hr,
"threshold_pace": threshold_pace,
}
if all(v is None for v in proposed.values()):
return "No settings provided. Pass at least one threshold to change."
current_list = await make_intervals_request(
url=f"/athlete/{athlete_id}/sport-settings", api_key=api_key
)
if isinstance(current_list, dict) and "error" in current_list:
return f"Error fetching current sport settings: {current_list.get('message')}"
records = [r for r in current_list if isinstance(r, dict)] if isinstance(current_list, list) else []
current = next((r for r in records if r.get("id") == settings_id), None)
if current is None:
return (
f"No sport settings found with ID {settings_id}. "
"Use get_sport_settings to list valid IDs."
)
changed: dict[str, Any] = {}
diff_lines: list[str] = []
for key, value in proposed.items():
if value is not None and current.get(key) != value:
changed[key] = value
diff_lines.append(f" {key}: {current.get(key)} -> {value}")
if not changed:
return "No changes — the provided values already match the current settings."
sport = ", ".join(str(t) for t in (current.get("types") or [])) or f"settings {settings_id}"
diff = "\n".join(diff_lines)
approved = False
if ctx is not None:
try:
elicited = await ctx.elicit(
message=f"Update {sport} thresholds?\n{diff}", schema=_ConfirmThresholdChange
)
action = getattr(elicited, "action", None)
if action in ("decline", "cancel"):
return "Sport settings unchanged — you declined."
data = getattr(elicited, "data", None)
approved = action == "accept" and bool(getattr(data, "confirm", False))
except Exception: # noqa: BLE001 - client without elicitation capability falls through
approved = False
if not approved and not confirm:
return (
f"⚠️ This will change your {sport} thresholds:\n{diff}\n\n"
"These drive ALL future load / intensity / zone calculations. "
"If the athlete confirms, re-run with confirm=true."
)
updated = dict(current)
updated.update(changed)
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/sport-settings/{settings_id}",
api_key=api_key,
method="PUT",
params={"recalcHrZones": recalc_hr_zones},
data=updated,
)
if isinstance(result, dict) and "error" in result:
return f"Error updating sport settings: {result.get('message')}"
body = result if isinstance(result, dict) else updated
return f"Updated {sport} settings:\n\n" + format_sport_settings(body)
+122 -29
View File
@@ -16,6 +16,47 @@ from intervals_mcp_server.utils.validation import resolve_date_params, validate_
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
# snake_case tool param -> Intervals.icu camelCase wellness field. `sleep_hours`
# is handled separately (converted to sleepSecs). Shared by the single-day and
# bulk write tools so their field mapping can never drift.
_WELLNESS_FIELD_MAP: list[tuple[str, str]] = [
("weight", "weight"),
("resting_hr", "restingHR"),
("hrv", "hrv"),
("sleep_quality", "sleepQuality"),
("calories_consumed", "kcalConsumed"),
("carbohydrates", "carbohydrates"),
("protein", "protein"),
("fat", "fatTotal"),
("hydration_volume", "hydrationVolume"),
("hydration_score", "hydration"),
("soreness", "soreness"),
("fatigue", "fatigue"),
("stress", "stress"),
("mood", "mood"),
("motivation", "motivation"),
("injury", "injury"),
("comments", "comments"),
("locked", "locked"),
]
def _wellness_payload(fields: dict[str, Any]) -> dict[str, Any]:
"""Map snake_case wellness fields to the Intervals.icu camelCase payload.
Only non-None values are included. ``sleep_hours`` becomes ``sleepSecs`` (with
-1 passing through unscaled as the clear sentinel).
"""
payload: dict[str, Any] = {}
for snake, camel in _WELLNESS_FIELD_MAP:
value = fields.get(snake)
if value is not None:
payload[camel] = value
sleep_hours = fields.get("sleep_hours")
if sleep_hours is not None:
payload["sleepSecs"] = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
return payload
@mcp.tool()
async def get_wellness_data(
@@ -136,35 +177,29 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
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}
payload = _wellness_payload(
{
"weight": weight,
"resting_hr": resting_hr,
"hrv": hrv,
"sleep_hours": sleep_hours,
"sleep_quality": sleep_quality,
"calories_consumed": calories_consumed,
"carbohydrates": carbohydrates,
"protein": protein,
"fat": fat,
"hydration_volume": hydration_volume,
"hydration_score": hydration_score,
"soreness": soreness,
"fatigue": fatigue,
"stress": stress,
"mood": mood,
"motivation": motivation,
"injury": injury,
"comments": comments,
"locked": locked,
}
)
if not payload:
return "No wellness fields provided. Pass at least one field to update."
@@ -187,3 +222,61 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
result["date"] = date
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
return f"Updated wellness for {date}."
@mcp.tool()
async def update_wellness_bulk(entries: list[dict[str, Any]]) -> str:
"""Create or update multiple days of wellness data in a single call.
Writes to PUT /athlete/{id}/wellness-bulk. Each entry is a dict with a ``date``
(YYYY-MM-DD) plus any of the same fields as update_wellness: weight, resting_hr,
hrv, sleep_hours, sleep_quality, calories_consumed, carbohydrates, protein, fat,
hydration_volume, hydration_score, soreness, fatigue, stress, mood, motivation,
injury, comments, locked. Pass -1 to clear a numeric field. Every date is
validated up front — if any entry is invalid the whole batch is rejected, so
there are no partial writes.
Args:
entries: List of per-day wellness dicts, each with a ``date`` and one or more fields.
"""
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
if not entries:
return "No entries provided. Pass at least one day to update."
if len(entries) > 92:
return f"Too many entries ({len(entries)}). Limit a bulk update to 92 days."
records: list[dict[str, Any]] = []
summaries: list[str] = []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
return f"Error: entry {i} is not an object."
raw_date = entry.get("date")
if not raw_date:
return f"Error: entry {i} is missing a 'date'."
try:
date = validate_date(str(raw_date))
except ValueError as exc:
return f"Error in entry {i}: {exc}"
payload = _wellness_payload(entry)
if not payload:
return f"Error: entry {i} ({date}) has no wellness fields to update."
payload["id"] = date
records.append(payload)
summaries.append(f"{date}: {', '.join(k for k in payload if k != 'id')}")
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness-bulk",
api_key=api_key,
method="PUT",
data=records,
)
if isinstance(result, dict) and "error" in result:
return f"Error updating wellness data: {result.get('message')}"
return f"Updated {len(records)} day(s):\n" + "\n".join(summaries)
+121
View File
@@ -75,6 +75,36 @@ def _patch_request(monkeypatch, result):
return calls
def _patch_seq(monkeypatch, results):
"""Patch make_intervals_request to return queued results, one per call."""
calls: list[dict] = []
seq = iter(results)
async def fake(**kwargs):
calls.append(kwargs)
return next(seq)
monkeypatch.setattr(athlete, "make_intervals_request", fake)
return calls
class _StubCtx:
"""Minimal stand-in for FastMCP Context.elicit used by the guardrail tests."""
def __init__(self, action="accept", confirm=True, raise_exc=False):
self._action = action
self._confirm = confirm
self._raise = raise_exc
self.elicit_calls = 0
async def elicit(self, message, schema): # noqa: ARG002 - signature parity
self.elicit_calls += 1
if self._raise:
raise RuntimeError("client does not support elicitation")
data = type("Data", (), {"confirm": self._confirm})()
return type("Result", (), {"action": self._action, "data": data})()
# --------------------------------------------------------------------------- #
# get_athlete_profile
# --------------------------------------------------------------------------- #
@@ -186,3 +216,94 @@ def test_get_athlete_summary_empty(monkeypatch):
def test_get_athlete_summary_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "bad"})
assert "Error fetching athlete summary: bad" in asyncio.run(athlete.get_athlete_summary())
# --------------------------------------------------------------------------- #
# update_sport_settings (dual-guardrail write)
# --------------------------------------------------------------------------- #
CURRENT_SS = [{"id": 100, "types": ["Ride"], "ftp": 280, "lthr": 165}]
def test_update_sport_settings_no_fields(monkeypatch):
calls = _patch_seq(monkeypatch, [])
out = asyncio.run(athlete.update_sport_settings(settings_id=100))
assert "No settings provided" in out
assert calls == [] # returns before any fetch
def test_update_sport_settings_refuses_without_confirm_or_ctx(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS]) # only the GET happens
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300))
assert "⚠️ This will change your Ride thresholds" in out
assert "ftp: 280 -> 300" in out
assert "re-run with confirm=true" in out
assert len(calls) == 1 and calls[0]["url"] == "/athlete/i1/sport-settings" # no PUT
def test_update_sport_settings_confirm_true_writes(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
out = asyncio.run(
athlete.update_sport_settings(settings_id=100, ftp=300, recalc_hr_zones=True, confirm=True)
)
put = calls[1]
assert put["method"] == "PUT"
assert put["url"] == "/athlete/i1/sport-settings/100"
assert put["params"] == {"recalcHrZones": True}
assert put["data"]["ftp"] == 300 # merged into the full record
assert "Updated Ride settings" in out
def test_update_sport_settings_elicit_accept_writes(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
ctx = _StubCtx(action="accept", confirm=True)
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert ctx.elicit_calls == 1
assert len(calls) == 2 and calls[1]["method"] == "PUT"
assert "Updated Ride settings" in out
def test_update_sport_settings_elicit_decline(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="decline")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
assert len(calls) == 1 # no PUT
def test_update_sport_settings_elicit_cancel(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="cancel")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
def test_update_sport_settings_elicit_unsupported_falls_back(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(raise_exc=True) # client without elicitation capability
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "re-run with confirm=true" in out
assert len(calls) == 1 # refused, no PUT
def test_update_sport_settings_unknown_id(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
out = asyncio.run(athlete.update_sport_settings(settings_id=999, ftp=300, confirm=True))
assert "No sport settings found with ID 999" in out
def test_update_sport_settings_no_op(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=280, confirm=True))
assert "No changes" in out
def test_update_sport_settings_fetch_error(monkeypatch):
_patch_seq(monkeypatch, [{"error": True, "message": "down"}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert "Error fetching current sport settings: down" in out
def test_update_sport_settings_put_error(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS, {"error": True, "message": "rejected"}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert "Error updating sport settings: rejected" in out
+85
View File
@@ -134,3 +134,88 @@ def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "Date: 2025-05-24" in out
assert "Date: N/A" not in out
# --------------------------------------------------------------------------- #
# update_wellness_bulk
# --------------------------------------------------------------------------- #
def test_update_wellness_bulk_success(monkeypatch):
calls = _patch_request(monkeypatch, [{"id": "2026-07-18"}, {"id": "2026-07-19"}])
out = asyncio.run(
wellness.update_wellness_bulk(
[
{"date": "2026-07-18", "weight": 80, "carbohydrates": 300},
{"date": "2026-07-19", "sleep_hours": 8},
]
)
)
call = calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/wellness-bulk"
body = call["data"]
assert isinstance(body, list) and len(body) == 2
assert body[0]["id"] == "2026-07-18"
assert body[0]["weight"] == 80
assert body[0]["carbohydrates"] == 300 # camelCase mapping shared with update_wellness
assert body[1]["sleepSecs"] == 8 * 3600
assert "Updated 2 day(s)" in out
def test_update_wellness_bulk_mapping_matches_single(monkeypatch):
# The bulk payload for a day must equal the single-day payload for the same fields
# (plus the id) — proving the shared _wellness_payload helper prevents drift.
fields = {"weight": 78, "resting_hr": 50, "sleep_hours": 7.5, "fat": 60, "locked": True}
from intervals_mcp_server.tools.wellness import _wellness_payload
single = _wellness_payload(fields)
calls = _patch_request(monkeypatch, [{}])
asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", **fields}]))
bulk_entry = {k: v for k, v in calls[0]["data"][0].items() if k != "id"}
assert bulk_entry == single
def test_update_wellness_bulk_invalid_date_rejects_whole_batch(monkeypatch):
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(
wellness.update_wellness_bulk(
[{"date": "2026-07-18", "weight": 80}, {"date": "not-a-date", "weight": 81}]
)
)
assert "Error in entry 1" in out
assert calls == [] # no partial write
def test_update_wellness_bulk_missing_date(monkeypatch):
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(wellness.update_wellness_bulk([{"weight": 80}]))
assert "entry 0 is missing a 'date'" in out
assert calls == []
def test_update_wellness_bulk_entry_no_fields(monkeypatch):
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18"}]))
assert "has no wellness fields" in out
assert calls == []
def test_update_wellness_bulk_empty(monkeypatch):
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(wellness.update_wellness_bulk([]))
assert "No entries provided" in out
assert calls == []
def test_update_wellness_bulk_too_many(monkeypatch):
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(
wellness.update_wellness_bulk([{"date": "2026-01-01", "weight": 80}] * 93)
)
assert "Too many entries" in out
assert calls == []
def test_update_wellness_bulk_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", "weight": 80}]))
assert "Error updating wellness data: boom" in out