Commit Graph

37 Commits

Author SHA1 Message Date
Chris Farhood 95d7681b03 ci: extract release notes from commitizen-style changelog headings
The release-notes awk matched Keep-a-Changelog "## [X.Y.Z]" brackets, but
commitizen writes "## vX.Y.Z (date)", so every release body fell back to the
"Release vX.Y.Z" stub. Match the "## v" heading form instead so the release
carries its actual changelog section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:07:40 -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
release-bot e724be283f bump: version 0.2.0 → 0.2.1 v0.2.1 2026-07-20 14:16:23 +00:00
Chris Farhood cfd968aced fix(release): sync uv.lock into the bump commit and tag
build-image / test (push) Successful in 23s
build-image / build (push) Successful in 19s
After cz bump changes the project version, regenerate uv.lock and amend it into
the bump commit (moving the tag) so every release tag stays consistent and
`uv sync --locked` works when the versioned image is built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 10:13:56 -04:00
Chris Farhood a949d0a5de chore: sync uv.lock to the 0.2.0 project version
cz bump updates pyproject.toml's version but not uv.lock's own-package entry,
which left `uv sync --locked` failing at the v0.2.0 release commit. Regenerate
the lock so pyproject and uv.lock agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 10:13:35 -04:00
release-bot 36022cd5f4 bump: version 0.1.0 → 0.2.0 v0.2.0 2026-07-20 14:07:18 +00:00
Chris Farhood 272519d8b0 ci: run the test job on uv instead of setup-python
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 3s
Replace actions/setup-python (which fails on a cold runner toolcache with a
broken python-versions prebuilt) with the self-contained uv installer, matching
release.yaml. Uses `uv sync --all-extras --locked --python 3.12` and
`uv run --locked pytest`, so the test gate — and therefore the versioned image
build behind it — no longer depends on a warm toolcache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 10:02:42 -04:00
Chris Farhood 7f8500199d ci: tag release image with semver so it can be pinned
Trigger the image build on v* tags and, on a version tag, publish the semver
(X.Y.Z) alongside :latest and the commit SHA. Previously only :latest and
:<sha> were pushed and the build never fired on the release tag, so there was
no stable version to pin against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:59:26 -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 74fb09972a fix: reset version to 0.1.0 baseline; let cz bump own versioning
The manual 0.1.0->0.2.0 bump conflicted with the automated release
workflow, which would have double-bumped to 0.3.0. Version is now driven
solely by `cz bump` from conventional commits (last tag v0.1.0 is the
baseline); CHANGELOG entries are generated at release time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:07:56 -04:00
Chris Farhood 407239296b ci: add release workflow (manual dispatch: cz bump + tag + Gitea release)
Cutting a release is now a workflow, not a local command. Uses uv/commitizen
(actions/setup-python is broken on the current runners). Supports auto-detect or
forced increment, and a dry_run mode.
2026-07-20 08:49:04 -04:00
Chris Farhood f766e68d0d chore: set up semantic versioning (Commitizen + CHANGELOG); bump 0.1.0 -> 0.2.0
Establishes the release process for the upcoming nutrition/wellness features (a
minor bump). Source of truth is pyproject [project].version; `uvx cz bump` derives
future versions from conventional commits and tags vX.Y.Z.
2026-07-20 08:43:11 -04:00
Chris Farhood 5ea85a060b ci: test build on updated runners
build-image / test (push) Failing after 11s
build-image / build (push) Has been skipped
v0.1.0
2026-07-07 22:15:01 -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 3348aea2f0 chore: stop gitignoring CLAUDE.md (now a tracked project doc)
build-image / test (push) Failing after 12m53s
build-image / build (push) Has been skipped
2026-07-07 07:53:52 -04:00
Chris Farhood f7f41013cb docs: add rewritten CLAUDE.md (force-add; it is gitignored)
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:23:12 -04:00
Chris Farhood 8fee4717dd docs: rewrite CLAUDE.md + README for the multi-tenant fork
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:22:03 -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 5f2c55c865 mcp: CORS on the streamable-http app (answer OPTIONS preflight without auth)
build-image / test (push) Failing after 3h13m8s
build-image / build (push) Has been skipped
OPTIONS /mcp returned 401 (auth layer rejecting the preflight) with no CORS
headers, which blocks browser-based MCP clients / connector setup. Build the app
explicitly and add CORSMiddleware (wildcard origin — bearer-token auth, no cookies)
so preflight is answered 200 and Mcp-Session-Id/WWW-Authenticate are exposed.
2026-07-06 22:13:36 -04:00
Chris Farhood 41b9c7a99a mcp: default to stateful sessions + SSE (Claude Desktop needs Mcp-Session-Id)
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 23s
stateless_http + json_response meant no session id was issued, so Claude Desktop
connected but never loaded tools. Default to stateful + SSE; both are env-toggleable
(MCP_STATELESS_HTTP / MCP_JSON_RESPONSE) for multi-replica deployments.
2026-07-06 21:37:33 -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 8f3abbde8f build: include alembic in image for migration initContainer
build-image / test (push) Successful in 1m34s
build-image / build (push) Successful in 26s
2026-07-04 19:47:54 -04:00
Chris Farhood 8204675a66 feat(db): Alembic migration for users table (async env, DATABASE_URL)
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 24s
Adds alembic/ (async env.py driven by DATABASE_URL) and the 0001 users-table
migration matching the model. enabled now has a DB-level server_default of false
(secure default even for non-ORM inserts). Verified upgrade/downgrade on sqlite.
2026-07-04 19:35:55 -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 7c36850b72 ci: measure coverage by package name + editable install (fix 0% in CI)
build-image / test (push) Successful in 12s
build-image / build (push) Successful in 16s
2026-07-04 18:43:26 -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 1ec2a76d1e ci: lean Dockerfile (drop build-essential), split+timeout build steps, buildkit
build-image / build (push) Successful in 20s
2026-07-04 18:09:17 -04:00
Chris Farhood 329d8b3078 ci: push image with REGISTRY_TOKEN (write:package)
build-image / build (push) Failing after 10m7s
2026-07-04 15:47:21 -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