12 Commits

Author SHA1 Message Date
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
16 changed files with 514 additions and 37 deletions
+28 -16
View File
@@ -3,6 +3,7 @@ name: build-image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
permissions:
@@ -18,13 +19,14 @@ jobs:
ATHLETE_ID: i1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (editable, with dev extras)
run: pip install --quiet -e ".[dev]"
- name: Install uv (self-contained; avoids the cold-cache setup-python failure)
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies (Python 3.12, locked)
run: uv sync --all-extras --locked --python 3.12
- name: Test + coverage gate (>=90%)
run: pytest
run: uv run --locked pytest
build:
needs: test
@@ -46,15 +48,25 @@ jobs:
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
- name: Build image
- name: Build and push image
run: |
docker build --progress=plain \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${GITHUB_SHA}" \
.
# Always tag the immutable commit SHA. On a version tag (refs/tags/vX.Y.Z)
# also publish the semver (X.Y.Z) so images can be pinned, and move :latest.
# 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
run: |
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${GITHUB_SHA}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}"
BUILD_ARGS=""
for r in ${REFS}; do BUILD_ARGS="${BUILD_ARGS} -t ${r}"; done
docker build --progress=plain ${BUILD_ARGS} .
for r in ${REFS}; do docker push "${r}"; done
echo "pushed: ${REFS}"
+97
View File
@@ -0,0 +1,97 @@
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).
body=$(awk "/^## \\[$v\\]/{f=1;next} /^## \\[/{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"
+42
View File
@@ -0,0 +1,42 @@
# 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.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]
name = "intervalsicu-mcp"
version = "0.1.0"
version = "0.2.1"
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12"
@@ -124,3 +124,14 @@ asyncio_default_fixture_loop_scope = "function"
[tool.uv]
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"
+6 -1
View File
@@ -86,7 +86,10 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
get_events,
)
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_wellness_data,
update_wellness,
)
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
create_custom_item,
@@ -113,6 +116,8 @@ __all__ = [
"delete_events_by_date_range",
"add_or_update_event",
"get_wellness_data",
"update_wellness",
"get_gear_list",
"get_athlete_power_curves",
"get_custom_items",
"get_custom_item_by_id",
+5 -1
View File
@@ -32,7 +32,10 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves,
)
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_wellness_data,
update_wellness,
)
def register_tools(mcp_instance: FastMCP) -> None:
@@ -70,4 +73,5 @@ __all__ = [
"get_athlete_power_curves",
"get_gear_list",
"get_wellness_data",
"update_wellness",
]
+123 -1
View File
@@ -4,11 +4,14 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from datetime import datetime
from typing import Any
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_wellness_entry
from intervals_mcp_server.utils.validation import resolve_date_params
from intervals_mcp_server.utils.validation import resolve_date_params, validate_date
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
@@ -65,3 +68,122 @@ async def get_wellness_data(
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
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}"
# Sleep is passed in hours but stored as seconds; -1 is the clear sentinel and
# must pass through unscaled.
sleep_secs: int | None = None
if sleep_hours is not None:
sleep_secs = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
# Map snake_case tool params to the Intervals.icu camelCase wellness fields.
field_map: list[tuple[str, Any]] = [
("weight", weight),
("restingHR", resting_hr),
("hrv", hrv),
("sleepSecs", sleep_secs),
("sleepQuality", sleep_quality),
("kcalConsumed", calories_consumed),
("carbohydrates", carbohydrates),
("protein", protein),
("fatTotal", fat),
("hydrationVolume", hydration_volume),
("hydration", hydration_score),
("soreness", soreness),
("fatigue", fatigue),
("stress", stress),
("mood", mood),
("motivation", motivation),
("injury", injury),
("comments", comments),
("locked", locked),
]
payload: dict[str, Any] = {k: v for k, v in field_map if v is not None}
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}."
+19 -1
View File
@@ -161,6 +161,20 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
for k, label in [
("ctl", "Fitness (CTL)"),
("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"),
("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"),
@@ -337,7 +351,11 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
entries.get("tempRestingHR")
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("")
training_metrics = _format_training_metrics(entries)
@@ -4,6 +4,7 @@ Date: 2025-05-24
Training Metrics:
- Fitness (CTL): 70.87253
- Fatigue (ATL): 91.97159
- Form (TSB): -21.1
- Ramp Rate: 6.997368
- CTL Load: 299
- ATL Load: 299
-2
View File
@@ -12,10 +12,8 @@ import time
import types
import jwt
import pytest
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
ISSUER = "https://auth.example/application/o/x/"
+1 -2
View File
@@ -8,7 +8,6 @@ and the error/empty branches.
import asyncio
import pytest
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"})
out = _run(
custom_items.create_custom_item(
name="Chart", item_type="FITNESS_CHART",
name="Chart", item_type="FITNESS_CHART",
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"})
out = _run(
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,
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"})
_run(
events.add_or_update_event(
workout_type="Ride", name="Threshold",
workout_type="Ride", name="Threshold",
event_id="e5", start_date="2026-07-10",
)
)
+32
View File
@@ -64,6 +64,38 @@ def test_format_wellness_entry():
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():
"""
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
+7 -7
View File
@@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch):
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
)
)
assert "Power Curves (Ride):" in result
@@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
durations=[5, 60],
)
)
assert "5s:" in result
@@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
include_normalised=False,
)
)
assert "W/kg" not in result
@@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
start_date="2026-01-01",
)
)
assert "Error" in result
@@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
activity_type="Ride",
this_season=False,
last_season=False,
)
)
assert "Error" in result
@@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch):
create_custom_item(
name="Activity Field",
item_type="ACTIVITY_FIELD",
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(
name="Bad Item",
item_type="FITNESS_CHART",
content="not valid json", # type: ignore[arg-type]
)
)
+136
View File
@@ -0,0 +1,136 @@
"""
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
Generated
+3 -3
View File
@@ -538,7 +538,7 @@ wheels = [
[[package]]
name = "intervalsicu-mcp"
version = "0.1.0"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -1346,8 +1346,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography", marker = "sys_platform != 'win32'" },
{ name = "jeepney", marker = "sys_platform != 'win32'" },
{ name = "cryptography" },
{ 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" }
wheels = [