24 Commits

Author SHA1 Message Date
release-bot d87ba87c4a bump: version 0.2.1 → 0.3.0
build-image / build (push) Successful in 20s
build-image / test (push) Successful in 17s
2026-07-20 20:45:31 +00:00
Chris Farhood 0e3e0264d6 Merge pull request 'feat: coaching-context tools (0.3.0)' (#6) from feat/coaching-context into main
build-image / test (push) Successful in 15s
build-image / build (push) Successful in 21s
2026-07-20 20:44:30 +00:00
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 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 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 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
26 changed files with 2773 additions and 44 deletions
+28 -16
View File
@@ -3,6 +3,7 @@ name: build-image
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -18,13 +19,14 @@ jobs:
ATHLETE_ID: i1 ATHLETE_ID: i1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - name: Install uv (self-contained; avoids the cold-cache setup-python failure)
with: run: |
python-version: "3.12" curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install (editable, with dev extras) echo "$HOME/.local/bin" >> "$GITHUB_PATH"
run: pip install --quiet -e ".[dev]" - name: Install dependencies (Python 3.12, locked)
run: uv sync --all-extras --locked --python 3.12
- name: Test + coverage gate (>=90%) - name: Test + coverage gate (>=90%)
run: pytest run: uv run --locked pytest
build: build:
needs: test needs: test
@@ -46,15 +48,25 @@ jobs:
run: | run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
- name: Build image - name: Build and push image
run: | run: |
docker build --progress=plain \ # Always tag the immutable commit SHA. On a version tag (refs/tags/vX.Y.Z)
-t "${IMAGE}:latest" \ # also publish the semver (X.Y.Z) so images can be pinned, and move :latest.
-t "${IMAGE}:${GITHUB_SHA}" \ # On a main-branch push, publish :latest.
. REFS="${IMAGE}:${GITHUB_SHA}"
case "${GITHUB_REF}" in
refs/tags/v*)
VERSION="${GITHUB_REF#refs/tags/v}"
REFS="${REFS} ${IMAGE}:${VERSION} ${IMAGE}:latest"
;;
*)
REFS="${REFS} ${IMAGE}:latest"
;;
esac
- name: Push image BUILD_ARGS=""
run: | for r in ${REFS}; do BUILD_ARGS="${BUILD_ARGS} -t ${r}"; done
docker push "${IMAGE}:latest" docker build --progress=plain ${BUILD_ARGS} .
docker push "${IMAGE}:${GITHUB_SHA}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}" for r in ${REFS}; do docker push "${r}"; done
echo "pushed: ${REFS}"
+102
View File
@@ -0,0 +1,102 @@
name: release
# Cut a release as an action, not a local command. Manually triggered ("Run
# workflow"): bumps the version from conventional commits (or a forced level),
# updates CHANGELOG.md, tags vX.Y.Z, pushes both, and creates a Gitea release.
# Uses uv/commitizen because actions/setup-python is broken on these runners.
on:
workflow_dispatch:
inputs:
increment:
description: "Bump level (auto = detect from conventional commits)"
required: false
default: auto
type: choice
options: [auto, PATCH, MINOR, MAJOR]
dry_run:
description: "Dry run — compute + show, but do NOT push or release"
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout (full history + tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
# Needs push + release rights. The auto token works with contents:write;
# if your runner's token can't push, set a PAT secret RELEASE_TOKEN.
token: ${{ secrets.RELEASE_TOKEN || github.token }}
- name: Install uv (self-contained; avoids the broken setup-python)
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Git identity
run: |
git config user.name "release-bot"
git config user.email "release-bot@farhoodlabs.com"
- name: Bump version + changelog + tag
id: bump
run: |
before=$(uvx --from commitizen cz version --project)
args="--yes"
[ "${{ inputs.increment }}" != "auto" ] && args="$args --increment ${{ inputs.increment }}"
if [ "${{ inputs.dry_run }}" = "true" ]; then
echo "== DRY RUN =="
uvx --from commitizen cz bump $args --dry-run || true
echo "do_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if uvx --from commitizen cz bump $args; then
after=$(uvx --from commitizen cz version --project)
# cz bump updates pyproject's version but not uv.lock's own-package
# entry, which would leave `uv sync --locked` failing at the release
# tag. Refresh the lock and fold it into the bump commit + tag.
uv lock
if ! git diff --quiet uv.lock; then
git add uv.lock
git commit --amend --no-edit
git tag -f "v$after"
fi
echo "version=$after" >> "$GITHUB_OUTPUT"
echo "do_release=true" >> "$GITHUB_OUTPUT"
echo "Bumped $before -> $after"
else
echo "No releasable commits since the last tag — nothing to do."
echo "do_release=false" >> "$GITHUB_OUTPUT"
fi
- name: Push commit + tag
if: ${{ steps.bump.outputs.do_release == 'true' }}
run: git push origin HEAD:${{ github.ref_name }} --follow-tags
- name: Create Gitea release
if: ${{ steps.bump.outputs.do_release == 'true' }}
run: |
v="${{ steps.bump.outputs.version }}"
# Body = this version's section from CHANGELOG.md (fallback to a stub).
# Commitizen writes headings as "## vX.Y.Z (date)", so match that form
# (not the Keep-a-Changelog "## [X.Y.Z]" brackets) and stop at the next.
# Escape dots and anchor on the trailing space so the start pattern is an
# exact version match ("## v0.3.0 " won't re-arm on "## v0.3.01 ...").
ve=$(printf '%s' "$v" | sed 's/\./\\./g')
body=$(awk "/^## v$ve /{f=1;next} /^## v/{f=0} f" CHANGELOG.md)
[ -z "$body" ] && body="Release v$v"
jq -n --arg tag "v$v" --arg name "v$v" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}' \
| curl -sf -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN || github.token }}" \
-H "Content-Type: application/json" \
--data @- \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
echo "Created release v$v"
+58
View File
@@ -0,0 +1,58 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
project is pre-1.0, new features bump the **minor** version and fixes bump the **patch**
version. Versions are cut with Commitizen (`uvx --from commitizen cz bump`), which updates
this file and tags `vX.Y.Z`.
## [0.1.0] — 2026-07-20
Baseline: multi-tenant fork of `intervals-mcp-server`. Per-user OAuth credentials with
AES-256-GCM-encrypted Intervals.icu API keys (Better Auth), streamable-HTTP transport with
CORS, and the full activity / event / wellness / power-curve / gear / custom-item toolset.
[0.1.0]: https://git.farh.net/farhoodlabs/intervalsicu-mcp/releases/tag/v0.1.0
## v0.3.0 (2026-07-20)
### Feat
- **readiness**: add get_training_readiness synthesizer
- **writes**: add update_wellness_bulk and update_sport_settings
- **workouts**: add workout library read tools (get_workouts, get_workout)
- **activities**: add search + best-efforts + interval-stats tools
- **athlete**: add profile, sport-settings, and summary read tools
### Fix
- **athlete**: drop athlete-tag filter, harden write guardrail, guard empty echo
- **wellness**: reject unrecognized bulk fields instead of silently dropping
- **readiness**: calendar-anchored windows, disjoint RHR baseline, SWC floor
## v0.2.1 (2026-07-20)
### Fix
- **release**: sync uv.lock into the bump commit and tag
## v0.2.0 (2026-07-20)
### Feat
- **wellness**: add update_wellness write tool, computed Form (TSB), date-label fix
### Fix
- **wellness**: harden Form/TSB and date rendering from code review
- reset version to 0.1.0 baseline; let cz bump own versioning
## v0.1.0 (2026-07-20)
### Feat
- **db**: Alembic migration for users table (async env, DATABASE_URL)
- **multi-tenant**: resolve per-caller credentials in every tool
- **multi-tenant**: data layer, encryption, and per-request credential resolver
+12 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "intervalsicu-mcp" name = "intervalsicu-mcp"
version = "0.1.0" version = "0.3.0"
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)" description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -124,3 +124,14 @@ asyncio_default_fixture_loop_scope = "function"
[tool.uv] [tool.uv]
index-url = "https://pypi.org/simple" index-url = "https://pypi.org/simple"
# Semantic versioning via Commitizen (conventional commits). Source of truth is the
# [project].version above. Bump + changelog + tag with: uvx --from commitizen cz bump
# (feat -> minor, fix -> patch; pre-1.0 so breaking changes stay in 0.x). Tags are vX.Y.Z.
[tool.commitizen]
name = "cz_conventional_commits"
version_provider = "pep621"
tag_format = "v$version"
major_version_zero = true
update_changelog_on_bump = true
changelog_file = "CHANGELOG.md"
+1 -1
View File
@@ -154,7 +154,7 @@ async def make_intervals_request(
api_key: str | None = None, api_key: str | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
method: str = "GET", method: str = "GET",
data: dict[str, Any] | None = None, data: dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]: ) -> dict[str, Any] | list[dict[str, Any]]:
""" """
Make a request to the Intervals.icu API with proper error handling. Make a request to the Intervals.icu API with proper error handling.
+33 -1
View File
@@ -71,6 +71,9 @@ config = get_config()
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators) # Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
# Import tool functions for re-export # Import tool functions for re-export
from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402 from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402
get_activity_best_efforts,
get_activity_interval_stats,
search_activities,
add_activity_message, add_activity_message,
get_activities, get_activities,
get_activity_details, get_activity_details,
@@ -86,7 +89,23 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
get_events, get_events,
) )
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402 from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402 from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
get_training_readiness,
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,
get_workouts,
)
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402 from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402 from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item, create_custom_item,
@@ -107,12 +126,25 @@ __all__ = [
"get_activity_intervals", "get_activity_intervals",
"get_activity_messages", "get_activity_messages",
"get_activity_streams", "get_activity_streams",
"search_activities",
"get_activity_best_efforts",
"get_activity_interval_stats",
"get_events", "get_events",
"get_event_by_id", "get_event_by_id",
"delete_event", "delete_event",
"delete_events_by_date_range", "delete_events_by_date_range",
"add_or_update_event", "add_or_update_event",
"get_wellness_data", "get_wellness_data",
"update_wellness",
"update_wellness_bulk",
"get_training_readiness",
"get_athlete_profile",
"get_sport_settings",
"get_athlete_summary",
"update_sport_settings",
"get_workouts",
"get_workout",
"get_gear_list",
"get_athlete_power_curves", "get_athlete_power_curves",
"get_custom_items", "get_custom_items",
"get_custom_item_by_id", "get_custom_item_by_id",
+28 -1
View File
@@ -10,9 +10,12 @@ from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
# Note: Tools register themselves via @mcp.tool() decorators when imported # Note: Tools register themselves via @mcp.tool() decorators when imported
from intervals_mcp_server.tools.activities import ( # noqa: F401 from intervals_mcp_server.tools.activities import ( # noqa: F401
get_activities, get_activities,
get_activity_best_efforts,
get_activity_details, get_activity_details,
get_activity_interval_stats,
get_activity_intervals, get_activity_intervals,
get_activity_streams, get_activity_streams,
search_activities,
) )
from intervals_mcp_server.tools.events import ( # noqa: F401 from intervals_mcp_server.tools.events import ( # noqa: F401
add_or_update_event, add_or_update_event,
@@ -32,7 +35,19 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves, get_athlete_power_curves,
) )
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401 from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401 from intervals_mcp_server.tools.wellness import ( # noqa: F401
get_training_readiness,
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
def register_tools(mcp_instance: FastMCP) -> None: def register_tools(mcp_instance: FastMCP) -> None:
@@ -57,6 +72,9 @@ __all__ = [
"get_activity_details", "get_activity_details",
"get_activity_intervals", "get_activity_intervals",
"get_activity_streams", "get_activity_streams",
"search_activities",
"get_activity_best_efforts",
"get_activity_interval_stats",
"get_events", "get_events",
"get_event_by_id", "get_event_by_id",
"delete_event", "delete_event",
@@ -70,4 +88,13 @@ __all__ = [
"get_athlete_power_curves", "get_athlete_power_curves",
"get_gear_list", "get_gear_list",
"get_wellness_data", "get_wellness_data",
"update_wellness",
"update_wellness_bulk",
"get_training_readiness",
"get_athlete_profile",
"get_sport_settings",
"get_athlete_summary",
"update_sport_settings",
"get_workouts",
"get_workout",
] ]
+115 -1
View File
@@ -14,7 +14,14 @@ from intervals_mcp_server.tools.gear import (
resolve_gear_for_activity, resolve_gear_for_activity,
resolve_gear_for_activities, resolve_gear_for_activities,
) )
from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals from intervals_mcp_server.utils.formatting import (
format_activity_message,
format_activity_search_results,
format_activity_summary,
format_best_efforts,
format_interval_stats,
format_intervals,
)
from intervals_mcp_server.utils.validation import resolve_date_params from intervals_mcp_server.utils.validation import resolve_date_params
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
@@ -405,3 +412,110 @@ async def add_activity_message(
if msg_id is not None: if msg_id is not None:
return f"Successfully added message (ID: {msg_id}) to activity {activity_id}." return f"Successfully added message (ID: {msg_id}) to activity {activity_id}."
return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually." return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually."
@mcp.tool()
async def search_activities(query: str, limit: int = 20) -> str:
"""Search the athlete's activities by name/keyword.
Args:
query: Search text matched against activity name/description (required).
limit: Maximum number of results to return (default 20).
"""
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
if not query or not query.strip():
return "Error: a non-empty search query is required."
params: dict[str, Any] = {"q": query.strip(), "limit": limit}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/activities/search", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error searching activities: {result.get('message')}"
results = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
if not results:
return f"No activities found matching '{query}'."
return format_activity_search_results(results)
@mcp.tool()
async def get_activity_best_efforts(
activity_id: str,
stream: str = "watts",
duration: int | None = None,
distance: float | None = None,
count: int | None = None,
) -> str:
"""Get the best efforts (peak values over windows) for an activity.
Args:
activity_id: The Intervals.icu activity ID.
stream: Data stream to analyze — e.g. "watts", "heartrate", "pace" (default "watts").
duration: Optional window duration in seconds to target.
distance: Optional window distance in meters to target.
count: Optional maximum number of efforts to return.
"""
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
params: dict[str, Any] = {"stream": stream}
if duration is not None:
params["duration"] = duration
if distance is not None:
params["distance"] = distance
if count is not None:
params["count"] = count
result = await make_intervals_request(
url=f"/activity/{activity_id}/best-efforts", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching best efforts: {result.get('message')}"
efforts = result.get("efforts") if isinstance(result, dict) else None
if not efforts:
return f"No best-effort data found for activity {activity_id} (stream: {stream})."
return format_best_efforts([e for e in efforts if isinstance(e, dict)], stream)
@mcp.tool()
async def get_activity_interval_stats(activity_id: str, start_index: int, end_index: int) -> str:
"""Compute aggregate stats for an index range of an activity's data streams.
start_index/end_index are positions in the activity's streams (as seen in the
streams or interval output). This computes metrics for that slice — it does NOT
list the activity's own intervals (use get_activity_intervals for that).
Args:
activity_id: The Intervals.icu activity ID.
start_index: Start position in the activity streams (required).
end_index: End position in the activity streams (required, > start_index).
"""
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
if start_index < 0 or end_index <= start_index:
return "Error: end_index must be greater than start_index and both non-negative."
params: dict[str, Any] = {"start_index": start_index, "end_index": end_index}
result = await make_intervals_request(
url=f"/activity/{activity_id}/interval-stats", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching interval stats: {result.get('message')}"
if not isinstance(result, dict) or not result:
return f"No interval stats found for activity {activity_id} ({start_index}-{end_index})."
return format_interval_stats(result)
+260
View File
@@ -0,0 +1,260 @@
"""
Athlete-profile and configuration MCP tools for Intervals.icu.
Read tools exposing the athlete's profile, per-sport training settings
(FTP / zones / thresholds), and training-load summaries — the context an AI
coach needs to reason about intensity and readiness.
"""
import logging
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
from intervals_mcp_server.utils.formatting import (
format_athlete_profile,
format_athlete_summary,
format_sport_settings,
)
from intervals_mcp_server.utils.validation import resolve_date_params
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
logger = logging.getLogger("intervals_icu_mcp_server")
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.
Returns identity and physiology basics (name, sex, weight, resting HR,
timezone, units, location). For per-sport FTP / zones / thresholds use
get_sport_settings instead.
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request(url=f"/athlete/{athlete_id}", api_key=api_key)
if isinstance(result, dict) and "error" in result:
return f"Error fetching athlete profile: {result.get('message')}"
if not isinstance(result, dict):
return "No athlete profile found."
return format_athlete_profile(result)
@mcp.tool()
async def get_sport_settings(sport: str | None = None) -> str:
"""Get the athlete's per-sport training settings (FTP, zones, thresholds).
These are the values that drive load, intensity and zone calculations across
Intervals.icu. Each record's "Settings ID" is the identifier update_sport_settings
uses to target a specific sport.
Args:
sport: Optional sport type to filter by (e.g. "Ride", "Run"). Matches the
record's sport types case-insensitively. If omitted, all sports are returned.
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/sport-settings", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching sport settings: {result.get('message')}"
records = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
if not records:
return "No sport settings found."
if sport:
want = sport.strip().lower()
records = [
r for r in records if any(want == str(t).lower() for t in (r.get("types") or []))
]
if not records:
return f"No sport settings found for sport '{sport}'."
return "\n\n".join(format_sport_settings(r) for r in records)
@mcp.tool()
async def get_athlete_summary(
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""Get a training-load summary (fitness/fatigue/form and totals) over a date range.
Note: the underlying endpoint's ``tags`` parameter filters *athletes* (a
coach-facing feature), not activities, so no tag filter is offered here.
Args:
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago).
end_date: End date in YYYY-MM-DD format (optional, defaults to today).
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
start_date, end_date = resolve_date_params(start_date, end_date)
params: dict[str, Any] = {"start": start_date, "end": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/athlete-summary", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching athlete summary: {result.get('message')}"
if isinstance(result, list):
summaries = [s for s in result if isinstance(s, dict)]
elif isinstance(result, dict):
summaries = [result]
else:
summaries = []
if not summaries:
return "No summary data found for the specified date range."
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)
# Guardrail. If the client answers an elicitation prompt, that answer is
# authoritative: anything short of accept-with-confirm is a refusal and we
# stop WITHOUT emitting the confirm=true fallback instructions (an agentic
# client could otherwise use them to bypass the refusal it just received).
# Only when elicitation is unavailable (no ctx, or the request itself fails)
# do we fall back to requiring the explicit confirm flag.
approved = False
elicitation_answered = False
if ctx is not None:
try:
elicited = await ctx.elicit(
message=f"Update {sport} thresholds?\n{diff}", schema=_ConfirmThresholdChange
)
elicitation_answered = True
action = getattr(elicited, "action", None)
data = getattr(elicited, "data", None)
approved = action == "accept" and bool(getattr(data, "confirm", False))
except Exception as exc: # noqa: BLE001 - capability absent or elicitation failed
logger.warning("Elicitation unavailable, falling back to confirm flag: %s", exc)
if elicitation_answered and not approved:
return "Sport settings unchanged — you did not confirm the change."
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')}"
# An empty-body 200 parses to {}; render the merged record in that case.
body = result if isinstance(result, dict) and result else updated
return f"Updated {sport} settings:\n\n" + format_sport_settings(body)
+277 -1
View File
@@ -4,15 +4,60 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data. This module contains tools for retrieving athlete wellness data.
""" """
from datetime import datetime, timedelta
from typing import Any
from intervals_mcp_server import credentials from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.credentials import CredentialError from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_wellness_entry from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_date_params from intervals_mcp_server.utils.readiness import assess_readiness, render_readiness
from intervals_mcp_server.utils.validation import resolve_date_params, validate_date
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 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() @mcp.tool()
async def get_wellness_data( async def get_wellness_data(
@@ -65,3 +110,234 @@ async def get_wellness_data(
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n" wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary return wellness_summary
@mcp.tool()
async def update_wellness( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
date: str | None = None,
weight: float | None = None,
resting_hr: int | None = None,
hrv: float | None = None,
sleep_hours: float | None = None,
sleep_quality: int | None = None,
calories_consumed: int | None = None,
carbohydrates: float | None = None,
protein: float | None = None,
fat: float | None = None,
hydration_volume: float | None = None,
hydration_score: int | None = None,
soreness: int | None = None,
fatigue: int | None = None,
stress: int | None = None,
mood: int | None = None,
motivation: int | None = None,
injury: int | None = None,
comments: str | None = None,
locked: bool | None = None,
) -> str:
"""Create or update the signed-in athlete's wellness record for a single day.
Writes nutrition, hydration, vitals, sleep, and subjective ratings to
Intervals.icu (PUT /athlete/{id}/wellness/{date}). Only the fields you pass are
sent; anything omitted is left untouched. To CLEAR an existing numeric value,
pass ``-1``. Set ``locked=True`` to stop Intervals.icu from overwriting these
values on its next sync from a connected device or app.
Args:
date: Day to update in YYYY-MM-DD format (optional, defaults to today).
weight: Body weight in kg.
resting_hr: Resting heart rate in bpm.
hrv: Heart rate variability (rMSSD).
sleep_hours: Sleep duration in hours (stored by Intervals.icu as seconds).
Pass -1 to clear.
sleep_quality: Sleep quality rating, as used in the Intervals.icu app.
calories_consumed: Energy intake in kcal.
carbohydrates: Carbohydrate intake in grams.
protein: Protein intake in grams.
fat: Fat intake in grams.
hydration_volume: Fluid intake volume, in your Intervals.icu units.
hydration_score: Subjective hydration score, as used in the app.
soreness: Subjective soreness rating, as used in the Intervals.icu app.
fatigue: Subjective fatigue rating, as used in the Intervals.icu app.
stress: Subjective stress rating, as used in the Intervals.icu app.
mood: Subjective mood rating, as used in the Intervals.icu app.
motivation: Subjective motivation rating, as used in the Intervals.icu app.
injury: Injury level rating, as used in the Intervals.icu app.
comments: Free-text note for the day.
locked: If True, lock the record so device/app syncs won't overwrite it.
"""
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
if not date:
date = datetime.now().strftime("%Y-%m-%d")
try:
date = validate_date(date)
except ValueError as exc:
return f"Error: {exc}"
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."
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness/{date}",
api_key=api_key,
method="PUT",
data=payload,
)
if isinstance(result, dict) and "error" in result:
return f"Error updating wellness data: {result.get('message')}"
# Intervals.icu echoes back the full updated record; render it for confirmation.
# If the echo omits the date, inject the one we wrote to so the confirmation
# body doesn't read "Date: N/A" under a dated header.
if isinstance(result, dict):
if not result.get("id") and not result.get("date"):
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."
# Recognized entry keys: the shared snake_case field names plus date/sleep_hours.
# Anything else (e.g. API-style camelCase like "restingHR") is rejected rather
# than silently dropped — otherwise values the caller asked to record would be
# lost behind a success message.
allowed_keys = {snake for snake, _ in _WELLNESS_FIELD_MAP} | {"date", "sleep_hours"}
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}"
unknown = sorted(set(entry) - allowed_keys)
if unknown:
return (
f"Error: entry {i} ({date}) has unrecognized field(s): {', '.join(unknown)}. "
f"Valid fields: {', '.join(sorted(allowed_keys - {'date'}))}."
)
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)
@mcp.tool()
async def get_training_readiness(days: int = 45) -> str:
"""Assess training readiness from recent wellness data.
Synthesizes the athlete's recent wellness history into a readiness read:
HRV-guided (7-day rolling lnRMSSD vs baseline +/- smallest worthwhile change),
resting-HR and sleep trends, and subjective inputs (soreness/fatigue/stress/
mood/motivation). When there is too little data — notably fewer than ~2 weeks
of HRV — the verdict is withheld rather than guessed, and the report lists which
signals it could and could not use.
Args:
days: How many days of history to analyze (default 45; minimum 14 is enforced).
"""
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
end = datetime.now()
start = end - timedelta(days=max(days, 14))
params = {"oldest": start.strftime("%Y-%m-%d"), "newest": end.strftime("%Y-%m-%d")}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching wellness data: {result.get('message')}"
records: list[dict[str, Any]] = []
if isinstance(result, dict):
for date_str, data in result.items():
if isinstance(data, dict):
data.setdefault("id", date_str)
records.append(data)
elif isinstance(result, list):
records = [r for r in result if isinstance(r, dict)]
if not records:
return "No wellness data found to assess readiness."
# Anchor the calendar windows on today so weeks-old data reads as "no recent
# data" rather than being presented as the athlete's current state.
return render_readiness(assess_readiness(records, reference_date=end.strftime("%Y-%m-%d")))
@@ -0,0 +1,72 @@
"""
Workout-library MCP tools for Intervals.icu.
Read tools exposing the athlete's reusable workout library (distinct from the
calendar *events* handled in tools/events.py).
"""
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_workout_details, format_workout_summary
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
@mcp.tool()
async def get_workouts(folder_id: int | None = None, sport_type: str | None = None) -> str:
"""List the athlete's reusable workout library.
Filtering is applied client-side (the API returns the full library).
Args:
folder_id: Optional folder ID to restrict results to one folder.
sport_type: Optional sport type to filter by (e.g. "Ride", "Run").
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request(url=f"/athlete/{athlete_id}/workouts", api_key=api_key)
if isinstance(result, dict) and "error" in result:
return f"Error fetching workouts: {result.get('message')}"
workouts = [w for w in result if isinstance(w, dict)] if isinstance(result, list) else []
if folder_id is not None:
workouts = [w for w in workouts if w.get("folder_id") == folder_id]
if sport_type:
want = sport_type.strip().lower()
workouts = [w for w in workouts if str(w.get("type", "")).lower() == want]
if not workouts:
return "No workouts found."
lines = [f"Workout Library ({len(workouts)}):", ""]
lines.extend(format_workout_summary(w) for w in workouts)
return "\n".join(lines)
@mcp.tool()
async def get_workout(workout_id: int) -> str:
"""Get a single library workout's full structure (steps and targets).
Args:
workout_id: The Intervals.icu workout ID.
"""
try:
athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/workouts/{workout_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching workout: {result.get('message')}"
if not isinstance(result, dict) or not result:
return f"No workout found with ID {workout_id}."
return format_workout_details(result)
+319 -1
View File
@@ -161,6 +161,20 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
for k, label in [ for k, label in [
("ctl", "Fitness (CTL)"), ("ctl", "Fitness (CTL)"),
("atl", "Fatigue (ATL)"), ("atl", "Fatigue (ATL)"),
]:
if entries.get(k) is not None:
training_metrics.append(f"- {label}: {entries[k]}")
# Form (a.k.a. TSB, Training Stress Balance) = CTL - ATL. Intervals.icu does
# not return this on the wellness record, so compute it when both components
# are present AND numeric. The isinstance guard keeps a non-numeric value from
# raising out of the formatter and taking down the whole wellness render.
# Positive = fresher/tapered, negative = carrying fatigue.
ctl, atl = entries.get("ctl"), entries.get("atl")
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
training_metrics.append(f"- Form (TSB): {ctl - atl:.1f}")
for k, label in [
("rampRate", "Ramp Rate"), ("rampRate", "Ramp Rate"),
("ctlLoad", "CTL Load"), ("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"), ("atlLoad", "ATL Load"),
@@ -337,7 +351,11 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
entries.get("tempRestingHR") entries.get("tempRestingHR")
lines = ["Wellness Data:"] lines = ["Wellness Data:"]
lines.append(f"Date: {entries.get('id', 'N/A')}") # The wellness record's own date lives in `id` (e.g. "2025-05-24"); some call
# sites also inject an explicit `date`. Prefer `date`, fall back to `id`. Use
# `or` chaining (not get-with-default) so a present-but-null `date` still falls
# back rather than rendering "Date: None".
lines.append(f"Date: {entries.get('date') or entries.get('id') or 'N/A'}")
lines.append("") lines.append("")
training_metrics = _format_training_metrics(entries) training_metrics = _format_training_metrics(entries)
@@ -402,6 +420,146 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
return "\n".join(lines) return "\n".join(lines)
def format_athlete_profile(athlete: dict[str, Any]) -> str:
"""Format an athlete profile into a readable string.
Renders identity/physiology basics. The embedded per-sport settings blob is
only summarised (a count) — get_sport_settings renders the detail.
"""
name = athlete.get("name") or " ".join(
p for p in [athlete.get("firstname"), athlete.get("lastname")] if p
) or "Unknown"
lines = ["Athlete Profile:", "", f"Name: {name}", f"ID: {athlete.get('id', 'N/A')}"]
weight = athlete.get("weight")
if weight is None:
weight = athlete.get("icu_weight")
for label, value, unit in [
("Sex", athlete.get("sex"), ""),
("Date of Birth", athlete.get("icu_date_of_birth"), ""),
("Weight", weight, "kg"),
("Resting HR", athlete.get("icu_resting_hr"), "bpm"),
("Timezone", athlete.get("timezone"), ""),
("Units", athlete.get("measurement_preference"), ""),
]:
if value is not None and value != "":
lines.append(f"{label}: {value}{(' ' + unit) if unit else ''}")
location = ", ".join(
p for p in [athlete.get("city"), athlete.get("state"), athlete.get("country")] if p
)
if location:
lines.append(f"Location: {location}")
if athlete.get("icu_coach"):
lines.append("Role: Coach")
if athlete.get("bio"):
lines.append(f"Bio: {athlete['bio']}")
sport_settings = athlete.get("sportSettings") or athlete.get("icu_type_settings")
if isinstance(sport_settings, list) and sport_settings:
lines.append(
f"Sport Settings: {len(sport_settings)} sport(s) configured "
"(use get_sport_settings for FTP/zones/thresholds)"
)
return "\n".join(lines)
def _format_zone_line(label: str, zones: Any, names: Any, unit: str = "") -> str | None:
"""Render a zone-boundary array, pairing with names when they line up."""
if not isinstance(zones, list) or not zones:
return None
if isinstance(names, list) and len(names) == len(zones):
parts = [f"{n}: {z}{unit}" for n, z in zip(names, zones, strict=True)]
else:
parts = [f"{z}{unit}" for z in zones]
return f"{label}: " + ", ".join(parts)
def format_sport_settings(settings: dict[str, Any]) -> str:
"""Format one per-sport settings record (FTP, zones, thresholds) into text.
The record ``id`` is always shown — it is the identifier update_sport_settings
needs to target a specific sport's settings.
"""
types = settings.get("types") or []
sport = ", ".join(str(t) for t in types) if types else "Unknown"
lines = [f"Sport Settings — {sport}:", f"Settings ID: {settings.get('id', 'N/A')}"]
power_bits = []
for key, label, unit in [
("ftp", "FTP", "W"),
("indoor_ftp", "Indoor FTP", "W"),
("w_prime", "W'", "J"),
("p_max", "Pmax", "W"),
]:
if settings.get(key) is not None:
power_bits.append(f"{label}: {settings[key]}{unit}")
if power_bits:
lines += ["", "Power:"] + [f"- {b}" for b in power_bits]
zone_line = _format_zone_line("Zones", settings.get("power_zones"), settings.get("power_zone_names"))
if zone_line:
lines.append(f"- {zone_line}")
hr_bits = []
for key, label in [("lthr", "LTHR"), ("max_hr", "Max HR")]:
if settings.get(key) is not None:
hr_bits.append(f"{label}: {settings[key]} bpm")
if hr_bits:
lines += ["", "Heart Rate:"] + [f"- {b}" for b in hr_bits]
zone_line = _format_zone_line("Zones", settings.get("hr_zones"), settings.get("hr_zone_names"))
if zone_line:
lines.append(f"- {zone_line}")
if settings.get("threshold_pace") is not None:
units = settings.get("pace_units", "")
lines += ["", "Pace:", f"- Threshold: {settings['threshold_pace']} {units}".rstrip()]
zone_line = _format_zone_line("Zones", settings.get("pace_zones"), settings.get("pace_zone_names"))
if zone_line:
lines.append(f"- {zone_line}")
defaults = []
for key, label in [("warmup_time", "Warmup"), ("cooldown_time", "Cooldown")]:
if settings.get(key) is not None:
defaults.append(f"{label}: {settings[key]}s")
if defaults:
lines += ["", "Defaults: " + ", ".join(defaults)]
return "\n".join(lines)
def format_athlete_summary(summary: dict[str, Any]) -> str:
"""Format a training-load summary (fitness/fatigue/form + totals) into text."""
lines: list[str] = []
if summary.get("date"):
lines.append(f"Period ending {summary['date']}:")
for key, label, unit in [
("count", "Activities", ""),
("moving_time", "Moving Time", "s"),
("distance", "Distance", "m"),
("total_elevation_gain", "Elevation Gain", "m"),
("training_load", "Training Load", ""),
("calories", "Calories", "kcal"),
("fitness", "Fitness (CTL)", ""),
("fatigue", "Fatigue (ATL)", ""),
("form", "Form (TSB)", ""),
("rampRate", "Ramp Rate", ""),
("eftp", "eFTP", "W"),
]:
if summary.get(key) is not None:
lines.append(f"- {label}: {summary[key]}{(' ' + unit) if unit else ''}")
categories = summary.get("byCategory")
if isinstance(categories, list) and categories:
lines.append("By category:")
for cat in categories:
if not isinstance(cat, dict):
continue
lines.append(
f" - {cat.get('category', '?')}: {cat.get('count', 0)} activities, "
f"load {cat.get('training_load', 'N/A')}, {cat.get('moving_time', 'N/A')}s"
)
return "\n".join(lines) if lines else "No summary metrics available."
def format_event_summary(event: dict[str, Any]) -> str: def format_event_summary(event: dict[str, Any]) -> str:
"""Format a basic event summary into a readable string.""" """Format a basic event summary into a readable string."""
@@ -658,3 +816,163 @@ def format_power_curves(
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
def format_activity_search_results(results: list[dict[str, Any]]) -> str:
"""Format activity search hits into a compact one-line-per-result list."""
lines = [f"Found {len(results)} activit{'y' if len(results) == 1 else 'ies'}:", ""]
for r in results:
date = r.get("start_date_local", "")
if isinstance(date, str) and len(date) > 10:
date = date[:10]
extra = []
if r.get("distance") is not None:
extra.append(f"{r['distance']}m")
if r.get("moving_time") is not None:
extra.append(f"{r['moving_time']}s")
if r.get("race"):
extra.append("RACE")
line = " | ".join([date or "?", str(r.get("type", "?")), str(r.get("name", "Unnamed"))])
if extra:
line += " (" + ", ".join(extra) + ")"
line += f" [id: {r.get('id', 'N/A')}]"
lines.append(line)
return "\n".join(lines)
def format_best_efforts(efforts: list[dict[str, Any]], stream: str) -> str:
"""Format best-effort windows for a stream (power/hr/pace) into text."""
lines = [f"Best Efforts ({stream}):", ""]
for e in efforts:
parts = []
if e.get("duration") is not None:
parts.append(_format_duration_label(int(e["duration"])))
if e.get("distance") is not None:
parts.append(f"{e['distance']}m")
label = " / ".join(parts) if parts else "effort"
idx = f"[idx {e.get('start_index', '?')}-{e.get('end_index', '?')}]"
lines.append(f"- {label}: avg {e.get('average', 'N/A')} {idx}")
return "\n".join(lines)
def format_interval_stats(interval: dict[str, Any]) -> str:
"""Format a computed interval-stats block (a single Interval) into text."""
lines = ["Interval Stats:", ""]
for key, label, unit in [
("moving_time", "Moving Time", "s"),
("distance", "Distance", "m"),
("average_watts", "Avg Power", "W"),
("weighted_average_watts", "Weighted Avg Power", "W"),
("max_watts", "Max Power", "W"),
("average_watts_kg", "Avg Power", "W/kg"),
("intensity", "Intensity", ""),
("training_load", "Training Load", ""),
("joules", "Work", "J"),
("decoupling", "Decoupling", "%"),
("average_heartrate", "Avg HR", "bpm"),
("max_heartrate", "Max HR", "bpm"),
("average_cadence", "Avg Cadence", "rpm"),
("average_speed", "Avg Speed", "m/s"),
("gap", "GAP", "m/s"),
]:
if interval.get(key) is not None:
lines.append(f"- {label}: {interval[key]}{(' ' + unit) if unit else ''}")
return "\n".join(lines)
def _format_step_intensity(step: dict[str, Any]) -> str:
"""Render a workout step's intensity target(s) from raw workout_doc JSON."""
bits = []
for key, label in [("power", ""), ("hr", "HR"), ("pace", "Pace"), ("cadence", "Cad")]:
v = step.get(key)
if not isinstance(v, dict):
continue
units = v.get("units", "")
if v.get("start") is not None and v.get("end") is not None:
val = f"{v['start']}-{v['end']}"
elif v.get("value") is not None:
val = f"{v['value']}"
else:
continue
bits.append(f"{(label + ' ') if label else ''}{val}{units}")
return ", ".join(bits)
def _format_workout_step(step: dict[str, Any], depth: int = 0) -> list[str]:
"""Recursively render one workout_doc step (handles repeat blocks). Depth-capped."""
indent = " " * (depth + 1)
if depth > 6:
return [f"{indent}- ...(nested too deep)"]
reps = step.get("reps")
substeps = step.get("steps")
if reps and isinstance(substeps, list):
lines = [f"{indent}{reps}x:"]
for sub in substeps:
if isinstance(sub, dict):
lines.extend(_format_workout_step(sub, depth + 1))
return lines
parts = []
if step.get("duration") is not None:
parts.append(_format_duration_label(int(step["duration"])))
if step.get("distance") is not None:
parts.append(f"{step['distance']}m")
tag = ""
if step.get("warmup"):
tag = " (warmup)"
elif step.get("cooldown"):
tag = " (cooldown)"
elif step.get("freeride"):
tag = " (free ride)"
intensity = _format_step_intensity(step)
if step.get("ramp") and intensity:
intensity = "ramp " + intensity
label = " ".join(parts) if parts else "step"
detail = f" @ {intensity}" if intensity else ""
text = step.get("text")
return [f"{indent}- {label}{detail}{tag}{('' + text) if text else ''}"]
def format_workout_summary(workout: dict[str, Any]) -> str:
"""Format one library workout as a compact one-line list entry."""
line = " | ".join([str(workout.get("name", "Unnamed")), str(workout.get("type", "?"))])
extra = []
if workout.get("icu_training_load") is not None:
extra.append(f"load {workout['icu_training_load']}")
if workout.get("moving_time") is not None:
extra.append(f"{workout['moving_time']}s")
if workout.get("folder_id") is not None:
extra.append(f"folder {workout['folder_id']}")
if extra:
line += " (" + ", ".join(extra) + ")"
return line + f" [id: {workout.get('id', 'N/A')}]"
def format_workout_details(workout: dict[str, Any]) -> str:
"""Format a library workout in full, including its structured steps."""
lines = [f"Workout: {workout.get('name', 'Unnamed')}", f"ID: {workout.get('id', 'N/A')}"]
for key, label, unit in [
("type", "Type", ""),
("sub_type", "Sub-type", ""),
("indoor", "Indoor", ""),
("moving_time", "Duration", "s"),
("distance", "Distance", "m"),
("icu_training_load", "Training Load", ""),
("icu_intensity", "Intensity", ""),
("carbs_per_hour", "Carbs", "g/hr"),
("folder_id", "Folder", ""),
]:
if workout.get(key) is not None:
lines.append(f"{label}: {workout[key]}{(' ' + unit) if unit else ''}")
if workout.get("description"):
lines.append(f"Description: {workout['description']}")
tags = workout.get("tags")
if isinstance(tags, list) and tags:
lines.append("Tags: " + ", ".join(str(t) for t in tags))
doc = workout.get("workout_doc")
if isinstance(doc, dict) and isinstance(doc.get("steps"), list) and doc["steps"]:
lines += ["", "Steps:"]
for step in doc["steps"]:
if isinstance(step, dict):
lines.extend(_format_workout_step(step))
return "\n".join(lines)
+330
View File
@@ -0,0 +1,330 @@
"""
Training-readiness computation for Intervals.icu wellness data.
Pure functions (no I/O) so they can be unit-tested on fixtures. The HRV method
follows Plews & Laursen: a rolling mean of ``ln(rMSSD)`` over the last 7 calendar
days compared to a baseline from the preceding ~30 days, with a "normal" band of
baseline mean +/- the smallest worthwhile change (SWC = 0.5 x baseline SD, with a
floor so a near-constant baseline can't produce a zero-width band). Resting HR,
sleep and subjective inputs are each compared to their own recent baseline.
All windows are **calendar-based**, anchored on ``reference_date`` (callers should
pass today): a metric whose samples are older than the window reports "no recent
data" instead of silently treating stale samples as current. Nothing is
fabricated: a metric with too little data in its window reports "no data" rather
than defaulting, and the overall verdict is withheld (not guessed) when the
objective signals are too sparse to be meaningful. Subjective fields use the
conventional Intervals.icu direction (soreness/fatigue/stress/injury: higher is
worse; mood/motivation: higher is better) and only ever contribute a soft
warning, never a hard alert.
"""
from __future__ import annotations
import math
import statistics
from datetime import date, timedelta
from typing import Any
_RECENT_DAYS = 7
_BASELINE_DAYS = 30
_MIN_HRV_RECENT = 4 # samples needed inside the 7-day window
_MIN_HRV_BASELINE = 7
_MIN_RHR_RECENT = 4
_MIN_RHR_BASELINE = 5
_MIN_SLEEP_BASELINE = 5
_MIN_SUBJ_BASELINE = 5
_SUBJ_LATEST_MAX_AGE = 3 # days; older subjective entries aren't "current" feelings
# Floor for the HRV smallest-worthwhile-change band, in ln(rMSSD) units. A
# near-constant baseline (coarsely-rounded device output, very steady athlete)
# would otherwise give SWC ~= 0 and flag trivial fluctuations as alerts. 0.05 ln
# units is ~5% in rMSSD — on the order of normal day-to-day variation.
_SWC_FLOOR = 0.05
_SUBJ_WORSE_HIGH = ("soreness", "fatigue", "stress", "injury")
_SUBJ_WORSE_LOW = ("motivation", "mood")
def _parse_date(value: Any) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except (ValueError, TypeError):
return None
def _dated_series(
records: list[dict[str, Any]], key: str, positive: bool = False
) -> list[tuple[date, float]]:
"""Date-sorted ``(date, value)`` pairs for ``key``; undated/non-numeric skipped."""
out: list[tuple[date, float]] = []
for r in records:
if not isinstance(r, dict):
continue
d = _parse_date(r.get("id") or r.get("date"))
v = r.get(key)
if d is None or not isinstance(v, (int, float)) or isinstance(v, bool):
continue
if positive and v <= 0:
continue
out.append((d, float(v)))
out.sort(key=lambda p: p[0])
return out
def _windows(
pairs: list[tuple[date, float]], ref: date
) -> tuple[list[float], list[float]]:
"""Split values into recent (last 7 calendar days) and baseline (30 before that)."""
recent_start = ref - timedelta(days=_RECENT_DAYS)
baseline_start = recent_start - timedelta(days=_BASELINE_DAYS)
recent = [v for d, v in pairs if recent_start < d <= ref]
baseline = [v for d, v in pairs if baseline_start < d <= recent_start]
return recent, baseline
def _newest_date(records: list[dict[str, Any]]) -> date | None:
dates = [
d
for d in (_parse_date(r.get("id") or r.get("date")) for r in records if isinstance(r, dict))
if d is not None
]
return max(dates) if dates else None
def _resolve_ref(records: list[dict[str, Any]], reference_date: str | None) -> date | None:
return _parse_date(reference_date) if reference_date else _newest_date(records)
def hrv_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""HRV readiness via 7-day rolling lnRMSSD vs baseline band (mean +/- SWC)."""
pairs = _dated_series(records, "hrv", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "HRV", "level": "nodata", "detail": "no HRV data"}
recent_vals, baseline_vals = _windows(pairs, ref)
if len(recent_vals) < _MIN_HRV_RECENT:
return {
"name": "HRV",
"level": "nodata",
"detail": f"only {len(recent_vals)} HRV sample(s) in the last {_RECENT_DAYS} days",
}
if len(baseline_vals) < _MIN_HRV_BASELINE:
return {
"name": "HRV",
"level": "nodata",
"detail": f"only {len(baseline_vals)} baseline day(s) — need >= {_MIN_HRV_BASELINE}",
}
recent_mean = statistics.mean(math.log(v) for v in recent_vals)
ln_base = [math.log(v) for v in baseline_vals]
base_mean = statistics.mean(ln_base)
swc = max(0.5 * statistics.pstdev(ln_base), _SWC_FLOOR)
if recent_mean < base_mean - swc:
return {
"name": "HRV",
"level": "alert",
"detail": "7-day lnHRV below baseline band — parasympathetic suppression",
}
if recent_mean > base_mean + swc:
return {
"name": "HRV",
"level": "warn",
"detail": "7-day lnHRV above baseline band — super-compensation, "
"or saturation if resting HR is also elevated",
}
return {"name": "HRV", "level": "ok", "detail": "7-day lnHRV within normal band"}
def rhr_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""Resting-HR readiness: 7-day mean vs a disjoint 30-day baseline, flag if >5% above."""
pairs = _dated_series(records, "restingHR", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "Resting HR", "level": "nodata", "detail": "no resting-HR data"}
recent_vals, baseline_vals = _windows(pairs, ref)
if len(recent_vals) < _MIN_RHR_RECENT:
return {
"name": "Resting HR",
"level": "nodata",
"detail": f"only {len(recent_vals)} RHR sample(s) in the last {_RECENT_DAYS} days",
}
if len(baseline_vals) < _MIN_RHR_BASELINE:
return {
"name": "Resting HR",
"level": "nodata",
"detail": f"only {len(baseline_vals)} baseline day(s) of RHR — need >= {_MIN_RHR_BASELINE}",
}
recent = statistics.mean(recent_vals)
base = statistics.mean(baseline_vals)
if base > 0 and (recent - base) / base > 0.05:
return {
"name": "Resting HR",
"level": "warn",
"detail": f"7-day RHR {recent:.0f} is >5% above baseline {base:.0f}",
}
return {
"name": "Resting HR",
"level": "ok",
"detail": f"7-day RHR {recent:.0f} near baseline {base:.0f}",
}
def sleep_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""Sleep readiness: last night (dated within a day of reference) vs baseline mean."""
pairs = _dated_series(records, "sleepSecs", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "Sleep", "level": "nodata", "detail": "no sleep data"}
last_date, last_secs = pairs[-1]
if (ref - last_date).days > 1:
return {
"name": "Sleep",
"level": "nodata",
"detail": f"no sleep logged since {last_date.isoformat()}",
}
baseline = [
v / 3600
for d, v in pairs
if d != last_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
]
if len(baseline) < _MIN_SLEEP_BASELINE:
return {"name": "Sleep", "level": "nodata", "detail": "not enough sleep data"}
last = last_secs / 3600
mean = statistics.mean(baseline)
if mean > 0 and last < 0.85 * mean:
return {
"name": "Sleep",
"level": "warn",
"detail": f"last night {last:.1f}h below baseline {mean:.1f}h",
}
return {
"name": "Sleep",
"level": "ok",
"detail": f"last night {last:.1f}h near baseline {mean:.1f}h",
}
def subjective_signals(
records: list[dict[str, Any]], reference_date: str | None = None
) -> list[dict[str, Any]]:
"""Soft warnings when a *current* subjective field has moved off baseline for the worse."""
signals: list[dict[str, Any]] = []
ref = _resolve_ref(records, reference_date)
if ref is None:
return signals
fields = [(f, True) for f in _SUBJ_WORSE_HIGH] + [(f, False) for f in _SUBJ_WORSE_LOW]
for field, worse_high in fields:
pairs = _dated_series(records, field)
if not pairs:
continue
latest_date, latest = pairs[-1]
if (ref - latest_date).days > _SUBJ_LATEST_MAX_AGE:
continue # stale entries aren't current feelings
baseline = [
v
for d, v in pairs
if d != latest_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
]
if len(baseline) < _MIN_SUBJ_BASELINE:
continue
mean = statistics.mean(baseline)
sd = statistics.pstdev(baseline) if len(baseline) > 1 else 0.0
threshold = max(sd, 0.5) # require a meaningful move, not noise
worse = (latest - mean > threshold) if worse_high else (mean - latest > threshold)
if worse:
direction = "elevated" if worse_high else "low"
signals.append(
{
"name": field.capitalize(),
"level": "warn",
"detail": f"{field} {direction} vs baseline ({latest:g} vs {mean:.1f})",
}
)
return signals
def form_context(records: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Latest Form (TSB = CTL - ATL) if both components are present."""
dated = sorted(
[r for r in records if isinstance(r, dict)],
key=lambda r: str(r.get("id") or r.get("date") or ""),
)
if not dated:
return None
latest = dated[-1]
ctl, atl = latest.get("ctl"), latest.get("atl")
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
return {"form": round(ctl - atl, 1), "ctl": ctl, "atl": atl}
return None
def assess_readiness(
records: list[dict[str, Any]], reference_date: str | None = None
) -> dict[str, Any]:
"""Produce a structured readiness assessment from wellness records.
``reference_date`` (YYYY-MM-DD) anchors the calendar windows — pass today so
stale data reads as "no recent data" instead of masquerading as current. If
omitted, the newest record's date is used (fixture-friendly, but blind to
how old that record is).
"""
core = [
hrv_signal(records, reference_date),
rhr_signal(records, reference_date),
sleep_signal(records, reference_date),
]
signals = core + subjective_signals(records, reference_date)
alerts = [s for s in signals if s["level"] == "alert"]
warns = [s for s in signals if s["level"] == "warn"]
core_with_data = [s for s in core if s["level"] != "nodata"]
if core[0]["level"] == "nodata" and len(core_with_data) < 2:
verdict = "insufficient"
elif alerts or len(warns) >= 3:
verdict = "red"
elif warns:
verdict = "amber"
else:
verdict = "green"
return {
"verdict": verdict,
"signals": signals,
"form": form_context(records),
"days": len(records),
}
_VERDICT_LABEL = {
"green": "🟢 Ready — signals within normal range",
"amber": "🟡 Caution — one or more signals off baseline",
"red": "🔴 Compromised — strong or multiple negative signals",
"insufficient": "⚪ Verdict withheld — not enough data to judge",
}
_LEVEL_ICON = {"ok": "", "warn": "!", "alert": "", "nodata": "·"}
def render_readiness(assessment: dict[str, Any]) -> str:
"""Render a readiness assessment into a plain-language report."""
lines = [
"Training Readiness:",
"",
_VERDICT_LABEL.get(assessment["verdict"], assessment["verdict"]),
f"(based on {assessment['days']} day(s) of wellness data)",
"",
"Signals:",
]
for s in assessment["signals"]:
lines.append(f" {_LEVEL_ICON.get(s['level'], '-')} {s['name']}: {s['detail']}")
form = assessment["form"]
if form:
lines += ["", f"Form (TSB): {form['form']} (CTL {form['ctl']} / ATL {form['atl']})"]
if assessment["verdict"] == "insufficient":
lines += [
"",
"Log daily HRV (and resting HR) for ~2+ weeks to enable a readiness verdict.",
]
return "\n".join(lines)
@@ -4,6 +4,7 @@ Date: 2025-05-24
Training Metrics: Training Metrics:
- Fitness (CTL): 70.87253 - Fitness (CTL): 70.87253
- Fatigue (ATL): 91.97159 - Fatigue (ATL): 91.97159
- Form (TSB): -21.1
- Ramp Rate: 6.997368 - Ramp Rate: 6.997368
- CTL Load: 299 - CTL Load: 299
- ATL Load: 299 - ATL Load: 299
+154
View File
@@ -0,0 +1,154 @@
"""
Tests for the 0.3.0 activity search + analytics tools in
intervals_mcp_server.tools.activities: search_activities,
get_activity_best_efforts, get_activity_interval_stats.
HTTP is stubbed at the module level; the autouse conftest fixture supplies the
caller credentials (athlete ``i1``).
"""
import asyncio
from intervals_mcp_server.tools import activities
def _patch_request(monkeypatch, result):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(activities, "make_intervals_request", fake)
return calls
# --------------------------------------------------------------------------- #
# search_activities
# --------------------------------------------------------------------------- #
SEARCH_HITS = [
{
"id": "a1",
"name": "Threshold intervals",
"start_date_local": "2026-07-18T07:00:00",
"type": "Ride",
"distance": 42000,
"moving_time": 5400,
"race": False,
},
{"id": "a2", "name": "Local crit", "start_date_local": "2026-07-15", "type": "Ride", "race": True},
]
def test_search_activities_success(monkeypatch):
calls = _patch_request(monkeypatch, SEARCH_HITS)
out = asyncio.run(activities.search_activities("threshold", limit=5))
assert calls[0]["url"] == "/athlete/i1/activities/search"
assert calls[0]["params"] == {"q": "threshold", "limit": 5}
assert "Found 2 activities" in out
assert "2026-07-18 | Ride | Threshold intervals" in out
assert "[id: a1]" in out
assert "RACE" in out # the crit
def test_search_activities_empty_query(monkeypatch):
calls = _patch_request(monkeypatch, SEARCH_HITS)
out = asyncio.run(activities.search_activities(" "))
assert "non-empty search query is required" in out
assert calls == [] # no request made
def test_search_activities_no_results(monkeypatch):
_patch_request(monkeypatch, [])
assert "No activities found matching 'zzz'" in asyncio.run(activities.search_activities("zzz"))
def test_search_activities_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
assert "Error searching activities: boom" in asyncio.run(activities.search_activities("x"))
# --------------------------------------------------------------------------- #
# get_activity_best_efforts
# --------------------------------------------------------------------------- #
BEST_EFFORTS = {
"efforts": [
{"duration": 300, "average": 320, "start_index": 100, "end_index": 400},
{"distance": 1000, "average": 305, "start_index": 500, "end_index": 700},
]
}
def test_best_efforts_success_and_param_passthrough(monkeypatch):
calls = _patch_request(monkeypatch, BEST_EFFORTS)
out = asyncio.run(
activities.get_activity_best_efforts("a1", stream="watts", duration=300, count=5)
)
params = calls[0]["params"]
assert calls[0]["url"] == "/activity/a1/best-efforts"
assert params["stream"] == "watts"
assert params["duration"] == 300
assert params["count"] == 5
assert "distance" not in params # None omitted
assert "Best Efforts (watts)" in out
assert "5m: avg 320" in out
assert "1000m: avg 305" in out
def test_best_efforts_empty(monkeypatch):
_patch_request(monkeypatch, {"efforts": []})
out = asyncio.run(activities.get_activity_best_efforts("a1"))
assert "No best-effort data found" in out
def test_best_efforts_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "nope"})
assert "Error fetching best efforts: nope" in asyncio.run(
activities.get_activity_best_efforts("a1")
)
# --------------------------------------------------------------------------- #
# get_activity_interval_stats
# --------------------------------------------------------------------------- #
INTERVAL_STATS = {
"moving_time": 1200,
"average_watts": 265,
"weighted_average_watts": 272,
"max_watts": 410,
"intensity": 0.88,
"training_load": 45,
"average_heartrate": 158,
"decoupling": 3.2,
}
def test_interval_stats_success(monkeypatch):
calls = _patch_request(monkeypatch, INTERVAL_STATS)
out = asyncio.run(activities.get_activity_interval_stats("a1", 100, 500))
assert calls[0]["url"] == "/activity/a1/interval-stats"
assert calls[0]["params"] == {"start_index": 100, "end_index": 500}
assert "Interval Stats:" in out
assert "Avg Power: 265 W" in out
assert "Weighted Avg Power: 272 W" in out
assert "Decoupling: 3.2 %" in out
def test_interval_stats_bad_indices(monkeypatch):
calls = _patch_request(monkeypatch, INTERVAL_STATS)
out = asyncio.run(activities.get_activity_interval_stats("a1", 500, 100))
assert "end_index must be greater than start_index" in out
assert calls == [] # no request
def test_interval_stats_empty(monkeypatch):
_patch_request(monkeypatch, {})
out = asyncio.run(activities.get_activity_interval_stats("a1", 0, 100))
assert "No interval stats found" in out
def test_interval_stats_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "bad range"})
assert "Error fetching interval stats: bad range" in asyncio.run(
activities.get_activity_interval_stats("a1", 0, 100)
)
+327
View File
@@ -0,0 +1,327 @@
"""
Tests for intervals_mcp_server.tools.athlete.
Covers the athlete-context read tools (get_athlete_profile, get_sport_settings,
get_athlete_summary): request shape, sport filtering, formatting of realistic
fixtures, and the empty / error / credential branches. Default caller credentials
come from the autouse fixture in conftest (athlete ``i1``).
"""
import asyncio
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import athlete
PROFILE = {
"id": "i1",
"name": "Test Athlete",
"sex": "M",
"weight": 72.5,
"icu_resting_hr": 48,
"timezone": "Europe/Madrid",
"measurement_preference": "meters",
"city": "Girona",
"country": "Spain",
"icu_coach": True,
"icu_type_settings": [{"id": 1}, {"id": 2}],
}
SPORT_SETTINGS = [
{
"id": 100,
"types": ["Ride", "VirtualRide"],
"ftp": 280,
"indoor_ftp": 275,
"w_prime": 22000,
"power_zones": [55, 75, 90, 105, 120],
"power_zone_names": ["Z1", "Z2", "Z3", "Z4", "Z5"],
"lthr": 165,
"max_hr": 190,
"hr_zones": [120, 145, 160, 175],
"threshold_pace": 4.2,
"pace_units": "MINS_KM",
"pace_zones": [3.5, 4.0, 4.5],
"warmup_time": 600,
"cooldown_time": 300,
},
{"id": 101, "types": ["Run"], "threshold_pace": 3.8, "pace_units": "MINS_KM"},
]
SUMMARY = [
{
"date": "2026-07-20",
"count": 12,
"moving_time": 43200,
"distance": 320000,
"training_load": 640,
"fitness": 78.5,
"fatigue": 71.0,
"form": 7.5,
"eftp": 285,
"byCategory": [{"category": "Ride", "count": 8, "training_load": 500, "moving_time": 32400}],
}
]
def _patch_request(monkeypatch, result):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(athlete, "make_intervals_request", fake)
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
# --------------------------------------------------------------------------- #
def test_get_athlete_profile_success(monkeypatch):
calls = _patch_request(monkeypatch, PROFILE)
out = asyncio.run(athlete.get_athlete_profile())
assert calls[0]["url"] == "/athlete/i1"
assert "Name: Test Athlete" in out
assert "Weight: 72.5 kg" in out
assert "Resting HR: 48 bpm" in out
assert "Location: Girona, Spain" in out
assert "Role: Coach" in out
assert "2 sport(s) configured" in out
def test_get_athlete_profile_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "nope"})
assert "Error fetching athlete profile: nope" in asyncio.run(athlete.get_athlete_profile())
def test_get_athlete_profile_non_dict(monkeypatch):
_patch_request(monkeypatch, [])
assert "No athlete profile found" in asyncio.run(athlete.get_athlete_profile())
def test_get_athlete_profile_credential_error(monkeypatch):
async def _deny():
raise CredentialError("not approved")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
assert "not approved" in asyncio.run(athlete.get_athlete_profile())
# --------------------------------------------------------------------------- #
# get_sport_settings
# --------------------------------------------------------------------------- #
def test_get_sport_settings_all(monkeypatch):
calls = _patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings())
assert calls[0]["url"] == "/athlete/i1/sport-settings"
assert "Sport Settings — Ride, VirtualRide" in out
assert "Settings ID: 100" in out
assert "FTP: 280W" in out
assert "Z1: 55, Z2: 75" in out # power zones paired with names
assert "LTHR: 165 bpm" in out
assert "Threshold: 4.2 MINS_KM" in out
assert "Settings ID: 101" in out # second record rendered too
def test_get_sport_settings_filter_hit(monkeypatch):
_patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings(sport="run")) # case-insensitive
assert "Settings ID: 101" in out
assert "Settings ID: 100" not in out
def test_get_sport_settings_filter_miss(monkeypatch):
_patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings(sport="Swim"))
assert "No sport settings found for sport 'Swim'" in out
def test_get_sport_settings_empty(monkeypatch):
_patch_request(monkeypatch, [])
assert "No sport settings found" in asyncio.run(athlete.get_sport_settings())
def test_get_sport_settings_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
assert "Error fetching sport settings: boom" in asyncio.run(athlete.get_sport_settings())
# --------------------------------------------------------------------------- #
# get_athlete_summary
# --------------------------------------------------------------------------- #
def test_get_athlete_summary_success(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
out = asyncio.run(athlete.get_athlete_summary(start_date="2026-06-20", end_date="2026-07-20"))
call = calls[0]
assert call["url"] == "/athlete/i1/athlete-summary"
assert call["params"]["start"] == "2026-06-20"
assert call["params"]["end"] == "2026-07-20"
assert "Fitness (CTL): 78.5" in out
assert "Form (TSB): 7.5" in out
assert "By category:" in out
assert "Ride: 8 activities" in out
def test_get_athlete_summary_defaults_dates(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
asyncio.run(athlete.get_athlete_summary())
# resolve_date_params fills both ends with YYYY-MM-DD
assert len(calls[0]["params"]["start"]) == 10
assert len(calls[0]["params"]["end"]) == 10
def test_get_athlete_summary_empty(monkeypatch):
_patch_request(monkeypatch, [])
assert "No summary data found" in asyncio.run(athlete.get_athlete_summary())
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 "did not confirm" in out
assert "confirm=true" not in out # no bypass instructions after a refusal
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 "did not confirm" in out
def test_update_sport_settings_accept_without_confirm_refuses_hard(monkeypatch):
# Submitting the elicitation without ticking confirm is a refusal: the tool
# must stop and must NOT emit the confirm=true bypass instructions — and an
# explicit confirm=True param must not override the answered elicitation.
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="accept", confirm=False)
out = asyncio.run(
athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True, ctx=ctx)
)
assert "did not confirm" in out
assert "confirm=true" not in out
assert len(calls) == 1 # no PUT
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
def test_update_sport_settings_empty_echo_renders_merged(monkeypatch):
# An empty-body 200 parses to {}; the confirmation must render the merged
# record (with the new FTP), not format_sport_settings({}).
calls = _patch_seq(monkeypatch, [CURRENT_SS, {}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert len(calls) == 2
assert "FTP: 300W" in out
assert "Settings ID: 100" in out
-2
View File
@@ -12,10 +12,8 @@ import time
import types import types
import jwt import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
ISSUER = "https://auth.example/application/o/x/" ISSUER = "https://auth.example/application/o/x/"
+1 -2
View File
@@ -8,7 +8,6 @@ and the error/empty branches.
import asyncio import asyncio
import pytest
from intervals_mcp_server.tools import custom_items from intervals_mcp_server.tools import custom_items
@@ -60,7 +59,7 @@ def test_create_builds_full_payload(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"}) rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
out = _run( out = _run(
custom_items.create_custom_item( custom_items.create_custom_item(
name="Chart", item_type="FITNESS_CHART", name="Chart", item_type="FITNESS_CHART",
description="desc", content={"a": 1}, visibility="PRIVATE", description="desc", content={"a": 1}, visibility="PRIVATE",
) )
) )
+2 -2
View File
@@ -112,7 +112,7 @@ def test_add_event_posts_when_no_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e99"}) rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
out = _run( out = _run(
events.add_or_update_event( events.add_or_update_event(
workout_type="Ride", name="Threshold", workout_type="Ride", name="Threshold",
start_date="2026-07-10", moving_time=3600, distance=40000, start_date="2026-07-10", moving_time=3600, distance=40000,
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]), workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
) )
@@ -128,7 +128,7 @@ def test_update_event_puts_when_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e5"}) rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
_run( _run(
events.add_or_update_event( events.add_or_update_event(
workout_type="Ride", name="Threshold", workout_type="Ride", name="Threshold",
event_id="e5", start_date="2026-07-10", event_id="e5", start_date="2026-07-10",
) )
) )
+32
View File
@@ -64,6 +64,38 @@ def test_format_wellness_entry():
assert result == expected_result assert result == expected_result
def test_format_wellness_entry_computes_form_tsb():
"""Form (TSB) is computed as CTL - ATL when both are present."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50, "atl": 65})
assert "Form (TSB): -15.0" in result
def test_format_wellness_entry_no_form_without_both_components():
"""Form is omitted if either CTL or ATL is missing."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50})
assert "Form (TSB)" not in result
def test_format_wellness_entry_prefers_explicit_date_over_id():
"""An explicit `date` field wins over `id` for the Date line."""
result = format_wellness_entry({"id": "2024-06-01", "date": "2024-06-02", "ctl": 50})
assert "Date: 2024-06-02" in result
def test_format_wellness_entry_null_date_falls_back_to_id():
"""A present-but-null `date` falls back to `id`, not 'None'."""
result = format_wellness_entry({"id": "2024-06-01", "date": None, "ctl": 50})
assert "Date: 2024-06-01" in result
assert "Date: None" not in result
def test_format_wellness_entry_non_numeric_ctl_atl_does_not_crash():
"""Non-numeric ctl/atl must not raise; Form is simply omitted."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": "n/a", "atl": "n/a"})
assert "Form (TSB)" not in result
assert "Date: 2024-06-01" in result
def test_format_wellness_entry_include_all_fields(): def test_format_wellness_entry_include_all_fields():
""" """
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields. Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
+208
View File
@@ -0,0 +1,208 @@
"""
Tests for the training-readiness feature.
The pure compute layer (utils/readiness.py) is exercised directly on deterministic
fixtures; one integration test drives the get_training_readiness tool with the HTTP
layer stubbed. Fixtures are built so verdicts are unambiguous.
"""
import asyncio
from datetime import date, timedelta
from intervals_mcp_server.tools import wellness
from intervals_mcp_server.utils import readiness
def _days(specs: list[dict]) -> list[dict]:
"""Build wellness records with sequential dates from a list of field dicts."""
return [{"id": f"2026-06-{i + 1:02d}", **spec} for i, spec in enumerate(specs)]
def _days_ending_today(specs: list[dict]) -> list[dict]:
"""Like _days, but the last record is dated today (for tool-level tests)."""
start = date.today() - timedelta(days=len(specs) - 1)
return [
{"id": (start + timedelta(days=i)).isoformat(), **spec} for i, spec in enumerate(specs)
]
def _stable(n: int, **fields) -> list[dict]:
return _days([dict(fields) for _ in range(n)])
# --------------------------------------------------------------------------- #
# HRV signal
# --------------------------------------------------------------------------- #
def test_hrv_insufficient_data():
recs = _stable(10, hrv=50)
sig = readiness.hrv_signal(recs)
assert sig["level"] == "nodata"
def test_hrv_normal_band():
# 23 stable baseline days + 7 stable recent days -> within band
recs = _days([{"hrv": 50 + (i % 3)} for i in range(30)])
assert readiness.hrv_signal(recs)["level"] == "ok"
def test_hrv_suppressed_alert():
baseline = [{"hrv": 50 + (i % 3)} for i in range(23)]
recent = [{"hrv": 34} for _ in range(7)]
assert readiness.hrv_signal(_days(baseline + recent))["level"] == "alert"
def test_hrv_elevated_warn():
baseline = [{"hrv": 50 + (i % 3)} for i in range(23)]
recent = [{"hrv": 75} for _ in range(7)]
assert readiness.hrv_signal(_days(baseline + recent))["level"] == "warn"
def test_hrv_constant_baseline_small_dip_is_not_alert():
# A near-constant baseline gives SWC ~ 0; the floor must keep a trivial
# 50 -> 49 fluctuation from producing a false "Compromised" alert.
recs = _days([{"hrv": 50} for _ in range(23)] + [{"hrv": 49} for _ in range(7)])
assert readiness.hrv_signal(recs)["level"] == "ok"
# --------------------------------------------------------------------------- #
# RHR / sleep signals
# --------------------------------------------------------------------------- #
def test_rhr_elevated_warn():
recs = _days([{"restingHR": 48} for _ in range(23)] + [{"restingHR": 56} for _ in range(7)])
assert readiness.rhr_signal(recs)["level"] == "warn"
def test_rhr_normal_ok():
assert readiness.rhr_signal(_stable(20, restingHR=48))["level"] == "ok"
def test_rhr_minimum_days_is_nodata_not_self_baseline():
# With only 7 samples there is no disjoint baseline; a uniformly-elevated
# (ill) week must NOT read "ok" from being compared against itself.
sig = readiness.rhr_signal(_stable(7, restingHR=58))
assert sig["level"] == "nodata"
def test_sleep_short_warn():
recs = _days([{"sleepSecs": 28800} for _ in range(10)] + [{"sleepSecs": 18000}])
assert readiness.sleep_signal(recs)["level"] == "warn"
def test_sleep_nodata():
assert readiness.sleep_signal(_stable(3, sleepSecs=28800))["level"] == "nodata"
# --------------------------------------------------------------------------- #
# subjective signals (conventional direction)
# --------------------------------------------------------------------------- #
def test_subjective_fatigue_elevated_warns():
recs = _days([{"fatigue": 2} for _ in range(10)] + [{"fatigue": 4}])
sigs = readiness.subjective_signals(recs)
assert any(s["name"] == "Fatigue" and s["level"] == "warn" for s in sigs)
def test_subjective_stable_no_warning():
assert readiness.subjective_signals(_stable(10, fatigue=2, mood=3)) == []
# --------------------------------------------------------------------------- #
# overall verdict
# --------------------------------------------------------------------------- #
def test_verdict_green_all_stable():
recs = _days(
[{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(30)]
)
assert readiness.assess_readiness(recs)["verdict"] == "green"
def test_verdict_red_on_hrv_suppression():
recs = _days(
[{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(23)]
+ [{"hrv": 33, "restingHR": 57, "sleepSecs": 28800} for _ in range(7)]
)
assert readiness.assess_readiness(recs)["verdict"] == "red"
def test_verdict_insufficient_when_hrv_sparse_and_little_else():
# Only 3 days total, no HRV baseline and <2 other core signals with data.
recs = _stable(3, restingHR=48)
out = readiness.assess_readiness(recs)
assert out["verdict"] == "insufficient"
def test_verdict_uses_rhr_and_sleep_when_hrv_missing():
# No HRV, but RHR + sleep both have data -> a verdict is still produced (green here).
recs = _days([{"restingHR": 48, "sleepSecs": 28800} for _ in range(20)])
out = readiness.assess_readiness(recs)
assert out["verdict"] == "green"
assert any(s["name"] == "HRV" and s["level"] == "nodata" for s in out["signals"])
def test_form_context_computed():
recs = _days([{"ctl": 60, "atl": 70}])
assert readiness.form_context(recs) == {"form": -10.0, "ctl": 60, "atl": 70}
# --------------------------------------------------------------------------- #
# render + tool integration
# --------------------------------------------------------------------------- #
def test_stale_data_withholds_verdict():
# Daily logging that STOPPED 3 weeks ago must not produce a current verdict:
# with today as the reference date every calendar window is empty.
old = _days([{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(30)])
out = readiness.assess_readiness(old, reference_date=date.today().isoformat())
assert out["verdict"] == "insufficient"
assert all(s["level"] == "nodata" for s in out["signals"])
def test_sleep_not_logged_recently_is_nodata():
recs = _days([{"sleepSecs": 28800} for _ in range(10)])
sig = readiness.sleep_signal(recs, reference_date="2026-07-01") # 3 weeks later
assert sig["level"] == "nodata"
assert "no sleep logged since" in sig["detail"]
def test_render_insufficient_mentions_logging():
out = readiness.render_readiness(readiness.assess_readiness(_stable(3, restingHR=48)))
assert "Verdict withheld" in out
assert "Log daily HRV" in out
def test_get_training_readiness_tool(monkeypatch):
# Wellness API returns a date-keyed dict; the tool must normalize and assess it.
start = date.today() - timedelta(days=29)
records = {
(start + timedelta(days=i)).isoformat(): {
"hrv": 50 + (i % 3),
"restingHR": 48,
"sleepSecs": 28800,
}
for i in range(30)
}
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return records
monkeypatch.setattr(wellness, "make_intervals_request", fake)
out = asyncio.run(wellness.get_training_readiness(days=45))
assert calls[0]["url"] == "/athlete/i1/wellness"
assert "Training Readiness:" in out
assert "🟢 Ready" in out
def test_get_training_readiness_no_data(monkeypatch):
async def fake(**kwargs):
return {}
monkeypatch.setattr(wellness, "make_intervals_request", fake)
assert "No wellness data found" in asyncio.run(wellness.get_training_readiness())
def test_get_training_readiness_error(monkeypatch):
async def fake(**kwargs):
return {"error": True, "message": "down"}
monkeypatch.setattr(wellness, "make_intervals_request", fake)
assert "Error fetching wellness data: down" in asyncio.run(wellness.get_training_readiness())
+7 -7
View File
@@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch):
result = asyncio.run( result = asyncio.run(
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
) )
) )
assert "Power Curves (Ride):" in result assert "Power Curves (Ride):" in result
@@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
durations=[5, 60], durations=[5, 60],
) )
) )
assert "5s:" in result assert "5s:" in result
@@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
include_normalised=False, include_normalised=False,
) )
) )
assert "W/kg" not in result assert "W/kg" not in result
@@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
start_date="2026-01-01", start_date="2026-01-01",
) )
) )
assert "Error" in result assert "Error" in result
@@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
activity_type="Ride", activity_type="Ride",
this_season=False, this_season=False,
last_season=False, last_season=False,
) )
) )
assert "Error" in result assert "Error" in result
@@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch):
create_custom_item( create_custom_item(
name="Activity Field", name="Activity Field",
item_type="ACTIVITY_FIELD", item_type="ACTIVITY_FIELD",
content='{"expression": "icu_training_load"}', # type: ignore[arg-type] content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
) )
) )
@@ -744,7 +744,7 @@ def test_create_custom_item_with_invalid_json_content(monkeypatch):
create_custom_item( create_custom_item(
name="Bad Item", name="Bad Item",
item_type="FITNESS_CHART", item_type="FITNESS_CHART",
content="not valid json", # type: ignore[arg-type] content="not valid json", # type: ignore[arg-type]
) )
) )
+37 -5
View File
@@ -11,16 +11,28 @@ import pytest
from intervals_mcp_server import credentials from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import activities, custom_items, events, gear, power_curves, wellness from intervals_mcp_server.tools import (
activities,
athlete,
custom_items,
events,
gear,
power_curves,
wellness,
workouts,
)
# (tool callable, minimal required positional args) # (tool callable, minimal required positional args)
TOOL_CALLS = [ TOOL_CALLS: list[tuple] = [
(activities.get_activities, ()), (activities.get_activities, ()),
(activities.get_activity_details, ("1",)), (activities.get_activity_details, ("1",)),
(activities.get_activity_intervals, ("1",)), (activities.get_activity_intervals, ("1",)),
(activities.get_activity_streams, ("1",)), (activities.get_activity_streams, ("1",)),
(activities.get_activity_messages, ("1",)), (activities.get_activity_messages, ("1",)),
(activities.add_activity_message, ("1", "hi")), (activities.add_activity_message, ("1", "hi")),
(activities.search_activities, ("ride",)),
(activities.get_activity_best_efforts, ("1",)),
(activities.get_activity_interval_stats, ("1", 0, 100)),
(events.get_events, ()), (events.get_events, ()),
(events.get_event_by_id, ("e1",)), (events.get_event_by_id, ("e1",)),
(events.delete_event, ("e1",)), (events.delete_event, ("e1",)),
@@ -28,6 +40,15 @@ TOOL_CALLS = [
(events.add_or_update_event, ("Ride", "Name")), (events.add_or_update_event, ("Ride", "Name")),
(events.add_or_update_note, ("Name", "desc")), (events.add_or_update_note, ("Name", "desc")),
(wellness.get_wellness_data, ()), (wellness.get_wellness_data, ()),
(wellness.update_wellness, ()),
(wellness.update_wellness_bulk, ([],)),
(wellness.get_training_readiness, ()),
(athlete.get_athlete_profile, ()),
(athlete.get_sport_settings, ()),
(athlete.get_athlete_summary, ()),
(athlete.update_sport_settings, (1,)),
(workouts.get_workouts, ()),
(workouts.get_workout, (1,)),
(power_curves.get_athlete_power_curves, ()), (power_curves.get_athlete_power_curves, ()),
(gear.get_gear_list, ()), (gear.get_gear_list, ()),
(custom_items.get_custom_items, ()), (custom_items.get_custom_items, ()),
@@ -50,6 +71,17 @@ def test_tool_returns_message_when_unauthorized(monkeypatch, func, args):
assert result == "ACCOUNT NOT APPROVED" assert result == "ACCOUNT NOT APPROVED"
def test_all_20_tools_covered(): def test_all_tools_covered():
"""Guard: if a tool is added, add it here so its auth gate is tested.""" """Guard: every registered MCP tool must appear in TOOL_CALLS.
assert len(TOOL_CALLS) == 20
Compares against the live tool registry instead of a hand-maintained count,
so adding a tool without adding its auth-gate test fails loudly here.
"""
from intervals_mcp_server.mcp_instance import mcp
registered = {t.name for t in asyncio.run(mcp.list_tools())}
covered = {f.__name__ for f, _ in TOOL_CALLS}
assert covered == registered, (
f"auth-gate matrix out of sync: missing={sorted(registered - covered)} "
f"extra={sorted(covered - registered)}"
)
+235
View File
@@ -0,0 +1,235 @@
"""
Tests for intervals_mcp_server.tools.wellness.
Covers the wellness write tool (``update_wellness``): field mapping to the
Intervals.icu camelCase schema, the sleep hours->seconds conversion and the
``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and
the error / credential branches. The default caller credentials come from the
autouse fixture in conftest (athlete ``i1``).
"""
import asyncio
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import wellness
def _patch_request(monkeypatch, result):
"""Patch make_intervals_request; capture the call kwargs, return ``result``."""
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(wellness, "make_intervals_request", fake)
return calls
def test_update_wellness_maps_fields_and_puts(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78})
out = asyncio.run(
wellness.update_wellness(
date="2025-05-24",
weight=78,
resting_hr=50,
hrv=65.5,
sleep_hours=8,
calories_consumed=2200,
carbohydrates=300,
protein=140,
fat=70,
hydration_volume=2.5,
comments="felt good",
)
)
assert len(calls) == 1
call = calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/wellness/2025-05-24"
assert call["api_key"] == "testkey"
payload = call["data"]
assert payload["weight"] == 78
assert payload["restingHR"] == 50
assert payload["hrv"] == 65.5
assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds
assert payload["kcalConsumed"] == 2200
assert payload["carbohydrates"] == 300
assert payload["protein"] == 140
assert payload["fatTotal"] == 70
assert payload["hydrationVolume"] == 2.5
assert payload["comments"] == "felt good"
# Omitted fields must not be sent.
assert "mood" not in payload
assert "locked" not in payload
assert "Updated wellness for 2025-05-24" in out
def test_update_wellness_defaults_date_to_today(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "today"})
asyncio.run(wellness.update_wellness(weight=80))
# URL date segment defaults to today's date (YYYY-MM-DD, 10 chars).
date_seg = calls[0]["url"].rsplit("/", 1)[1]
assert len(date_seg) == 10 and date_seg.count("-") == 2
def test_update_wellness_no_fields_returns_message(monkeypatch):
calls = _patch_request(monkeypatch, {})
out = asyncio.run(wellness.update_wellness(date="2025-05-24"))
assert "No wellness fields provided" in out
assert calls == [] # no request made
def test_update_wellness_clear_with_negative_one(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1))
payload = calls[0]["data"]
assert payload["weight"] == -1
# -1 is the clear sentinel and must NOT be scaled to -3600.
assert payload["sleepSecs"] == -1
def test_update_wellness_locked_false_is_sent(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False))
assert calls[0]["data"]["locked"] is False
def test_update_wellness_error_path(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "Error updating wellness data: boom" in out
def test_update_wellness_invalid_date(monkeypatch):
calls = _patch_request(monkeypatch, {})
out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80))
assert out.startswith("Error:")
assert calls == []
def test_update_wellness_credential_error(monkeypatch):
async def _deny():
raise CredentialError("not approved")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "not approved" in out
def test_update_wellness_non_dict_result(monkeypatch):
# If the API returns a list (unexpected), we still confirm the write.
_patch_request(monkeypatch, [])
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert out == "Updated wellness for 2025-05-24."
def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
# If the API echo omits id/date, the confirmation body must not read "Date: N/A".
_patch_request(monkeypatch, {"weight": 80})
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_rejects_unknown_keys(monkeypatch):
# camelCase/API-style names must be rejected, not silently dropped: the value
# the caller asked to record would otherwise be lost behind a success message.
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(
wellness.update_wellness_bulk(
[{"date": "2026-07-18", "weight": 80, "restingHR": 50}]
)
)
assert "unrecognized field(s): restingHR" in out
assert "resting_hr" in out # the error names the valid fields
assert calls == [] # whole batch rejected, nothing written
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
+131
View File
@@ -0,0 +1,131 @@
"""
Tests for intervals_mcp_server.tools.workouts (0.3.0 workout library).
Covers get_workouts (list + client-side filters) and get_workout (full detail
with a nested workout_doc), plus empty / error / credential branches.
"""
import asyncio
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import workouts
LIBRARY = [
{"id": 10, "name": "VO2 5x5", "type": "Ride", "icu_training_load": 95, "moving_time": 3600, "folder_id": 1},
{"id": 11, "name": "Easy run", "type": "Run", "moving_time": 2400, "folder_id": 2},
]
WORKOUT_DETAIL = {
"id": 10,
"name": "VO2 5x5",
"type": "Ride",
"indoor": True,
"moving_time": 3600,
"icu_training_load": 95,
"description": "VO2max builder",
"tags": ["vo2", "key"],
"workout_doc": {
"steps": [
{"duration": 900, "power": {"value": 60, "units": "%ftp"}, "warmup": True},
{
"reps": 5,
"steps": [
{"duration": 300, "power": {"value": 115, "units": "%ftp"}, "text": "hard"},
{"duration": 300, "power": {"value": 50, "units": "%ftp"}, "text": "easy"},
],
},
{"duration": 600, "power": {"start": 60, "end": 40, "units": "%ftp"}, "cooldown": True},
]
},
}
def _patch_request(monkeypatch, result):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(workouts, "make_intervals_request", fake)
return calls
def test_get_workouts_all(monkeypatch):
calls = _patch_request(monkeypatch, LIBRARY)
out = asyncio.run(workouts.get_workouts())
assert calls[0]["url"] == "/athlete/i1/workouts"
assert "Workout Library (2)" in out
assert "VO2 5x5 | Ride (load 95, 3600s, folder 1) [id: 10]" in out
def test_get_workouts_filter_folder(monkeypatch):
_patch_request(monkeypatch, LIBRARY)
out = asyncio.run(workouts.get_workouts(folder_id=2))
assert "Easy run" in out
assert "VO2 5x5" not in out
def test_get_workouts_filter_sport(monkeypatch):
_patch_request(monkeypatch, LIBRARY)
out = asyncio.run(workouts.get_workouts(sport_type="ride"))
assert "VO2 5x5" in out
assert "Easy run" not in out
def test_get_workouts_empty(monkeypatch):
_patch_request(monkeypatch, [])
assert "No workouts found" in asyncio.run(workouts.get_workouts())
def test_get_workouts_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
assert "Error fetching workouts: boom" in asyncio.run(workouts.get_workouts())
def test_get_workout_detail_with_nested_doc(monkeypatch):
calls = _patch_request(monkeypatch, WORKOUT_DETAIL)
out = asyncio.run(workouts.get_workout(10))
assert calls[0]["url"] == "/athlete/i1/workouts/10"
assert "Workout: VO2 5x5" in out
assert "Training Load: 95" in out
assert "Tags: vo2, key" in out
assert "Steps:" in out
assert "15m @ 60%ftp (warmup)" in out
assert "5x:" in out # repeat block rendered
assert "5m @ 115%ftp — hard" in out # nested step
assert "10m @ 60-40%ftp (cooldown)" in out # power range (no ramp flag set)
def test_get_workout_ramp_step(monkeypatch):
_patch_request(
monkeypatch,
{
"id": 12,
"name": "Ramp test",
"workout_doc": {
"steps": [{"ramp": True, "power": {"start": 100, "end": 300, "units": "w"}}]
},
},
)
out = asyncio.run(workouts.get_workout(12))
assert "ramp 100-300w" in out
def test_get_workout_not_found(monkeypatch):
_patch_request(monkeypatch, {})
assert "No workout found with ID 99" in asyncio.run(workouts.get_workout(99))
def test_get_workout_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "nope"})
assert "Error fetching workout: nope" in asyncio.run(workouts.get_workout(10))
def test_get_workout_credential_error(monkeypatch):
async def _deny():
raise CredentialError("not approved")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
assert "not approved" in asyncio.run(workouts.get_workout(10))
Generated
+3 -3
View File
@@ -538,7 +538,7 @@ wheels = [
[[package]] [[package]]
name = "intervalsicu-mcp" name = "intervalsicu-mcp"
version = "0.1.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
@@ -1346,8 +1346,8 @@ name = "secretstorage"
version = "3.5.0" version = "3.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography", marker = "sys_platform != 'win32'" }, { name = "cryptography" },
{ name = "jeepney", marker = "sys_platform != 'win32'" }, { name = "jeepney" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [ wheels = [