feat: write tools — update_wellness_bulk + update_sport_settings (dual-guardrail) #5

Closed
opened 2026-07-20 19:26:40 +00:00 by Chris Farhood · 1 comment
Owner

Part of the 0.3.0 coaching-context milestone. Two write tools.

Tools

  • update_wellness_bulkPUT /athlete/{id}/wellness-bulk — multi-day wellness/nutrition writes (pairs with 0.2.0 update_wellness).
  • update_sport_settingsPUT /athlete/{id}/sport-settings/{id} — change FTP/LTHR/pace/zones. RISKY: drives all future load/intensity math.

update_sport_settings guardrail (dual mechanism)

Claude Desktop/mobile does not render MCP elicitation yet (Anthropic open issues),
so elicitation alone is insufficient. Belt-and-suspenders:

  1. ⚠️ docstring: model must surface exact old→new values and get approval.
  2. Native ctx.elicit() confirm dialog on capable clients (Claude Code); abort on decline/cancel.
  3. Fallback: if elicitation unsupported AND confirm=True not passed, REFUSE the write and
    return the old→new diff + warning, instructing a re-call with confirm=true.
    Write only on elicit-accept OR explicit confirm=True. First tool to use Context.

Notes

  • Verified: SDK exposes Context.elicit(message, schema).
  • Register + tests, including the no-confirm refusal path and error path.
Part of the **0.3.0** coaching-context milestone. Two write tools. ### Tools - `update_wellness_bulk` — `PUT /athlete/{id}/wellness-bulk` — multi-day wellness/nutrition writes (pairs with 0.2.0 `update_wellness`). - `update_sport_settings` — `PUT /athlete/{id}/sport-settings/{id}` — change FTP/LTHR/pace/zones. **RISKY**: drives all future load/intensity math. ### `update_sport_settings` guardrail (dual mechanism) Claude Desktop/mobile does not render MCP elicitation yet (Anthropic open issues), so elicitation alone is insufficient. Belt-and-suspenders: 1. `⚠️` docstring: model must surface exact old→new values and get approval. 2. Native `ctx.elicit()` confirm dialog on capable clients (Claude Code); abort on decline/cancel. 3. Fallback: if elicitation unsupported AND `confirm=True` not passed, REFUSE the write and return the old→new diff + warning, instructing a re-call with `confirm=true`. Write only on elicit-accept OR explicit `confirm=True`. First tool to use `Context`. ### Notes - Verified: SDK exposes `Context.elicit(message, schema)`. - Register + tests, including the no-confirm refusal path and error path.
Chris Farhood added this to the 0.3.0 milestone 2026-07-20 19:26:40 +00:00
Author
Owner

Implementation plan (0.3.0) — intended delivery model: Claude Opus 4.8

Branch: feat/coaching-context; one conventional feat: commit for this issue (lands after #2, which provides get_sport_settings for the read-modify-write and for surfacing settings ids).

Endpoints (verified — two gotchas found in the spec)

  • PUT /athlete/{id}/wellness-bulk — body is a JSON array of Wellness objects; each entry carries its date as id.
  • PUT /athlete/{athleteId}/sport-settings/{id} — body SportSettings, and query param recalcHrZones (boolean) is REQUIRED by the spec. Response echoes the updated SportSettings.

1. update_wellness_bulk (extend tools/wellness.py)

  • Signature: entries: list[dict] — each entry {date: 'YYYY-MM-DD', ...fields} using the SAME snake_case field names as update_wellness (weight, resting_hr, hrv, sleep_hours, calories_consumed, carbohydrates, protein, fat, hydration_volume, hydration_score, soreness, fatigue, stress, mood, motivation, injury, comments, locked).
  • Extract the existing field-mapping (snake_case -> camelCase, sleep hours->secs with -1 passthrough) from update_wellness into a shared module-level helper _wellness_payload(fields) -> dict so single and bulk cannot drift. Each payload gets id = validated date.
  • Validate every date up front (reject the whole batch on any invalid date — no partial writes). Cap batch size (e.g. 92 days) with a clear message.
  • Confirmation: summary line per day written ('2026-07-18: weight, carbohydrates, sleep'), not a full record dump.

2. update_sport_settings (in tools/athlete.py from #2) — DUAL-GUARDRAIL WRITE

  • Signature: settings_id: int, ftp=None, indoor_ftp=None, w_prime=None, lthr=None, max_hr=None, threshold_pace=None, recalc_hr_zones: bool = False, confirm: bool = False, ctx: Context | None = None (FastMCP injects ctx; first tool in the repo to use it).
  • Flow: GET /athlete/{athleteId}/sport-settings, locate record by settings_id (error if absent) -> build old->new diff of ONLY the provided fields -> guardrail -> merge into the full record -> PUT with recalcHrZones -> render the echoed settings via format_sport_settings.
  • Guardrail (as scoped in this issue):
    1. Docstring leads with a warning: thresholds drive ALL future load/intensity/zone math; never call speculatively; show old->new and get approval.
    2. If ctx present: await ctx.elicit(message_with_diff, ConfirmSchema) (pydantic model, single confirm: bool). decline/cancel -> return 'unchanged'. Wrap in try/except — clients without the elicitation capability fall through.
    3. Fallback: if not elicit-accepted and confirm is not True -> REFUSE with the old->new diff + instruction to re-call with confirm=true. Write happens only on elicit-accept OR explicit confirm=True.
  • No-op guard: if every provided value equals the current value, return 'no changes' without a PUT.

Tests

  • Bulk: shared-helper mapping parity with update_wellness (same fixture through both), invalid-date batch rejection, -1 passthrough, request shape (array body), error path.
  • Sport-settings: refusal path (no confirm, no ctx), explicit confirm=True writes, mocked ctx elicit accept/decline/cancel, elicitation-unsupported exception falls back to refusal, unknown settings_id, no-op guard, recalcHrZones passthrough, error dict. Mock Context with a stub — no real MCP session needed.
  • Coverage gate >=90%.

Estimate

~450 LOC incl. tests. Establishes the confirm/elicit convention for all future risky writes.

## Implementation plan (0.3.0) — intended delivery model: Claude Opus 4.8 **Branch:** `feat/coaching-context`; one conventional `feat:` commit for this issue (lands after #2, which provides `get_sport_settings` for the read-modify-write and for surfacing settings ids). ### Endpoints (verified — two gotchas found in the spec) - `PUT /athlete/{id}/wellness-bulk` — body is a **JSON array** of `Wellness` objects; each entry carries its date as `id`. - `PUT /athlete/{athleteId}/sport-settings/{id}` — body `SportSettings`, and query param `recalcHrZones` (boolean) is **REQUIRED** by the spec. Response echoes the updated `SportSettings`. ### 1. `update_wellness_bulk` (extend `tools/wellness.py`) - Signature: `entries: list[dict]` — each entry `{date: 'YYYY-MM-DD', ...fields}` using the SAME snake_case field names as `update_wellness` (weight, resting_hr, hrv, sleep_hours, calories_consumed, carbohydrates, protein, fat, hydration_volume, hydration_score, soreness, fatigue, stress, mood, motivation, injury, comments, locked). - Extract the existing field-mapping (snake_case -> camelCase, sleep hours->secs with -1 passthrough) from `update_wellness` into a shared module-level helper `_wellness_payload(fields) -> dict` so single and bulk cannot drift. Each payload gets `id` = validated date. - Validate every date up front (reject the whole batch on any invalid date — no partial writes). Cap batch size (e.g. 92 days) with a clear message. - Confirmation: summary line per day written ('2026-07-18: weight, carbohydrates, sleep'), not a full record dump. ### 2. `update_sport_settings` (in `tools/athlete.py` from #2) — DUAL-GUARDRAIL WRITE - Signature: `settings_id: int, ftp=None, indoor_ftp=None, w_prime=None, lthr=None, max_hr=None, threshold_pace=None, recalc_hr_zones: bool = False, confirm: bool = False, ctx: Context | None = None` (FastMCP injects `ctx`; first tool in the repo to use it). - Flow: GET `/athlete/{athleteId}/sport-settings`, locate record by `settings_id` (error if absent) -> build old->new diff of ONLY the provided fields -> guardrail -> merge into the full record -> PUT with `recalcHrZones` -> render the echoed settings via `format_sport_settings`. - Guardrail (as scoped in this issue): 1. Docstring leads with a warning: thresholds drive ALL future load/intensity/zone math; never call speculatively; show old->new and get approval. 2. If `ctx` present: `await ctx.elicit(message_with_diff, ConfirmSchema)` (pydantic model, single `confirm: bool`). decline/cancel -> return 'unchanged'. Wrap in try/except — clients without the elicitation capability fall through. 3. Fallback: if not elicit-accepted and `confirm is not True` -> REFUSE with the old->new diff + instruction to re-call with `confirm=true`. Write happens only on elicit-accept OR explicit `confirm=True`. - No-op guard: if every provided value equals the current value, return 'no changes' without a PUT. ### Tests - Bulk: shared-helper mapping parity with `update_wellness` (same fixture through both), invalid-date batch rejection, -1 passthrough, request shape (array body), error path. - Sport-settings: refusal path (no confirm, no ctx), explicit `confirm=True` writes, mocked ctx elicit accept/decline/cancel, elicitation-unsupported exception falls back to refusal, unknown `settings_id`, no-op guard, `recalcHrZones` passthrough, error dict. Mock `Context` with a stub — no real MCP session needed. - Coverage gate >=90%. ### Estimate ~450 LOC incl. tests. Establishes the confirm/elicit convention for all future risky writes.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: farhoodlabs/intervalsicu-mcp#5