Commit Graph

20 Commits

Author SHA1 Message Date
Chris Farhood 7e3e1a772c ci: exact-version changelog match; test: auth-gate matrix syncs with registry
The release-notes awk start pattern was an unterminated prefix match ("## v0.3.0"
also re-armed on "## v0.3.01"); escape dots and anchor on the trailing space.

test_tool_auth's hand-counted matrix had drifted (update_wellness from 0.2.0 and
all 11 new tools were missing). Add all 12 and replace the count guard with a
comparison against the live mcp tool registry so drift fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:52 -04:00
Chris Farhood 700cad57ef fix(athlete): drop athlete-tag filter, harden write guardrail, guard empty echo
- get_athlete_summary no longer offers a tags filter: the endpoint's tags param
  filters ATHLETES (coach-facing), not activities, so it produced falsely-empty
  or wrongly-unfiltered summaries (confirmed against the OpenAPI description).
- update_sport_settings: an answered elicitation is now authoritative — accept
  without the confirm tick is a refusal that stops WITHOUT emitting the
  confirm=true fallback instructions (an agentic client could use them to bypass
  the refusal), and an explicit confirm param cannot override it. The elicit
  except no longer swallows failures silently (logged), and an empty-body PUT
  echo renders the merged record instead of an empty settings block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:52 -04:00
Chris Farhood e5b9606bb8 fix(wellness): reject unrecognized bulk fields instead of silently dropping
update_wellness_bulk kept only recognized snake_case keys and discarded the rest
(e.g. API-style camelCase like restingHR) while reporting success — silent data
loss across up to 92 days. Entries with unknown fields now reject the whole
batch with a message naming the bad and valid field names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:36 -04:00
Chris Farhood 91dace2e7c fix(readiness): calendar-anchored windows, disjoint RHR baseline, SWC floor
Address three confirmed review findings:
- Windows were sample-count based, so "7-day" and "last night" claims could be
  built from weeks-old data. All signals now use calendar windows anchored on a
  reference date (the tool passes today); stale metrics report "no recent data"
  and the verdict is withheld instead of presenting old samples as current.
- rhr_signal's `or vals[:-1]` fallback compared the recent week against itself at
  the sample minimum, reading a uniformly-ill week as "ok". The baseline is now
  disjoint by construction and insufficient baselines return nodata.
- The HRV SWC band had no floor, so a near-constant baseline flagged trivial
  fluctuations (50->49) as red "parasympathetic suppression". SWC now floors at
  0.05 ln units (~5% rMSSD, on the order of normal day-to-day variation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:36 -04:00
Chris Farhood 055dc8be21 feat(readiness): add get_training_readiness synthesizer
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
2026-07-20 15:57:35 -04:00
Chris Farhood 9a66183d39 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
2026-07-20 15:54:10 -04:00
Chris Farhood e18e05e02c feat(workouts): add workout library read tools (get_workouts, get_workout)
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
2026-07-20 15:49:17 -04:00
Chris Farhood 65585c53b5 feat(activities): add search + best-efforts + interval-stats tools
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
2026-07-20 15:46:35 -04:00
Chris Farhood 28119f1761 feat(athlete): add profile, sport-settings, and summary read tools
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
2026-07-20 15:44:05 -04:00
Chris Farhood f1ac56609e fix(wellness): harden Form/TSB and date rendering from code review
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
2026-07-20 09:51:39 -04:00
Chris Farhood ba441aa1ee chore: clean up pre-existing ruff violations in tests
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
2026-07-20 09:34:27 -04:00
Chris Farhood f2159d3ca5 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
2026-07-20 09:28:31 -04:00
Chris Farhood 62056ad86b Revert "security: enforce JWT audience + require sub in token verification"
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 21s
This reverts commit 69fe8a9233.
2026-07-07 18:03:14 -04:00
Chris Farhood 69fe8a9233 security: enforce JWT audience + require sub in token verification
build-image / test (push) Successful in 1m5s
build-image / build (push) Successful in 48s
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>
2026-07-07 09:30:09 -04:00
Chris Farhood f080e0847f test: mock uvicorn.run in streamable-http start test (CORS app path)
build-image / test (push) Successful in 22s
build-image / build (push) Successful in 26s
2026-07-06 22:20:36 -04:00
Chris Farhood e50be99374 auth: accept EdDSA tokens + soft audience check (Better Auth compatibility)
build-image / test (push) Successful in 15s
build-image / build (push) Successful in 20s
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.
2026-07-05 21:55:52 -04:00
Chris Farhood f067f9639a feat(multi-tenant): resolve per-caller credentials in every tool
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 42s
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>
2026-07-04 19:28:27 -04:00
Chris Farhood 31eb45c3f8 feat(multi-tenant): data layer, encryption, and per-request credential resolver
build-image / test (push) Successful in 19s
build-image / build (push) Successful in 19s
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>
2026-07-04 19:07:10 -04:00
Chris Farhood 43bbbb6bbb test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate
build-image / test (push) Failing after 53s
build-image / build (push) Has been skipped
New suites assert real behavior, not just that code runs:
- test_types: workout serialization round-trips (recursive steps, camelCase
  keys, enum conversion) + __str__ formatting.
- test_api_client: request construction (URL/method/auth/body) and the full
  HTTP status-code -> message mapping.
- test_auth: RS256 JWT verification — valid -> AccessToken; expired/wrong-aud/
  wrong-issuer/wrong-key/missing-claim -> None; audience slash variants.
- test_server_setup: transport selection + start_server dispatch.
- test_events / test_activities / test_custom_items: request payloads
  (create vs update, POST/PUT/DELETE), delete accounting, JSON-content parsing,
  and error/empty branches.

Enforce >=90 via pytest --cov-fail-under=90; CI test job now gates the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:39:20 -04:00
Chris Farhood 935abf86d4 Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s
- 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>
2026-07-04 15:07:46 -04:00