Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e724be283f | |||
| cfd968aced | |||
| a949d0a5de | |||
| 36022cd5f4 | |||
| 272519d8b0 | |||
| 7f8500199d | |||
| f1ac56609e | |||
| ba441aa1ee | |||
| f2159d3ca5 | |||
| 74fb09972a | |||
| 407239296b | |||
| f766e68d0d |
+28
-16
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user