New pure-compute utils/readiness.py (stdlib only, fully unit-tested) assesses
readiness from wellness history: HRV via Plews & Laursen 7-day rolling lnRMSSD vs
baseline +/- SWC, resting-HR and sleep trends, and conventional-direction
subjective inputs (soft warnings only). The get_training_readiness tool fetches
the window, normalizes the date-keyed API response, and renders a banded verdict
with the contributing signals. Verdict is withheld (not fabricated) when HRV is
sparse and fewer than two other core signals have data.
Implements #1. Completes the 0.3.0 coaching-context milestone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
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
get_workouts lists the reusable library (client-side folder/sport filters);
get_workout renders a single workout including its structured workout_doc steps
via a defensive, depth-capped recursive formatter (repeats, ramps, warmup/
cooldown, power/hr/pace targets). New tools/workouts.py; registered.
Implements #4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
search_activities queries by name/keyword; get_activity_best_efforts returns
peak values over windows for a stream; get_activity_interval_stats computes
aggregate metrics for an arbitrary stream index range (distinct from the
per-interval get_activity_intervals). Spec-required params enforced (q, stream,
start/end index). Formatters added; tools registered in server.py and __init__.
Implements #3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
Closes the biggest coaching-context gap: expose the athlete's identity/physiology
(get_athlete_profile), per-sport FTP/zones/thresholds (get_sport_settings, with an
optional sport filter and the settings id needed for future writes), and a
training-load summary over a range (get_athlete_summary). New tools/athlete.py plus
formatters in utils/formatting.py; registered in server.py and tools/__init__.py.
Implements #2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
Address findings from the pre-merge review:
- Form (TSB) now computes only when ctl/atl are numeric (isinstance guard),
so a non-numeric value can no longer raise out of format_wellness_entry and
take down the entire wellness render.
- The Date line uses `or` chaining so a present-but-null `date` falls back to
`id` instead of rendering "Date: None".
- update_wellness injects the written date into the API echo when it lacks
id/date, so the confirmation body can't read "Date: N/A" under a dated header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
Remove unused imports (F401) and strip trailing whitespace (W291/W293) flagged
by ruff in test files. Pre-existing debt unrelated to any single feature; CI runs
pytest but not ruff, so these had accumulated. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
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
Closes the audience-binding gap (RFC 9068): tokens minted by the issuer for a
different resource are now rejected at /mcp, and subject-less tokens are refused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Better Auth signs access tokens with EdDSA (Ed25519), not RS256. Accept EdDSA
(+ RS256/ES256), and validate issuer + signature + expiry strictly while checking
audience softly — single-resource server behind a dedicated AS with dynamic DCR
client ids, so issuer + signature is the trust boundary. Adds an EdDSA test.
All 20 tools now drop the athlete_id/api_key parameters and instead resolve the
authenticated caller's stored, enabled credentials via
credentials.resolve_caller_credentials() (get_access_token().subject -> store).
Security: there is no tool parameter a caller can pass to supply a key, so a
disabled/unapproved user cannot bypass the admin-approval gate — each tool
returns a helpful "not approved / set up your credentials" message instead.
Gear resolution now uses the caller's athlete id rather than an env var.
Tests: conftest autouse fixture runs tool tests as an enabled user; a
parametrized test asserts every tool refuses when unauthorized; existing tool
tests updated (no more athlete_id/api_key kwargs). 221 passing at 91.5%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for multi-user operation (Phase 1):
- crypto.py: AES-256-GCM encrypt/decrypt for the per-user Intervals API key,
key from INTERVALS_ENC_KEY (base64 32 bytes). Random nonce per message.
- db/models.py + db/session.py: SQLAlchemy 2.0 async User model (keyed on the
Authentik sub; api_key stored encrypted; enabled = admin-approval gate) and a
lazy async engine/sessionmaker from DATABASE_URL.
- store.py: async CRUD. New users created disabled; login never flips enabled;
get_active_credentials returns decrypted creds only for an enabled user that
has them.
- credentials.py: resolve_caller_credentials() maps get_access_token().subject
to that user's stored creds, falling back to env config only when unauthenticated
(stdio/local).
- Tests (SQLite in-memory, no infra): crypto round-trip/tamper, store gating,
resolver paths. Suite 199 passing at 90.4% (gate holds).
Deps: sqlalchemy[asyncio], asyncpg, alembic (+ aiosqlite for tests). Pin py3.12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching
current Claude clients; the old 2025-06-18 server never got a tools/list on
the connector surface).
- Bake transport config into code: stateless_http + json_response for HTTP
(single JSON body instead of a 34KB SSE stream, which the connector pipeline
handles far more reliably).
- Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth,
configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the
runtime FastMCP.__init__ monkeypatch from the k8s deployment command.
- Accept token audience with/without trailing slash (RFC 8707 clients use the
slash-normalised resource metadata value).
- Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to
build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>