16 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
19 changed files with 2328 additions and 42 deletions
+15 -1
View File
@@ -59,6 +59,15 @@ jobs:
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"
@@ -76,7 +85,12 @@ jobs:
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)
# 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}' \
+41
View File
@@ -15,3 +15,44 @@ AES-256-GCM-encrypted Intervals.icu API keys (Better Auth), streamable-HTTP tran
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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "intervalsicu-mcp"
version = "0.1.0"
version = "0.3.0"
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12"
+1 -1
View File
@@ -154,7 +154,7 @@ async def make_intervals_request(
api_key: str | None = None,
params: dict[str, Any] | None = None,
method: str = "GET",
data: dict[str, Any] | None = None,
data: dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
"""
Make a request to the Intervals.icu API with proper error handling.
+27
View File
@@ -71,6 +71,9 @@ config = get_config()
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
# Import tool functions for re-export
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,
get_activities,
get_activity_details,
@@ -87,9 +90,22 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
)
from intervals_mcp_server.tools.gear import get_gear_list # 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.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item,
@@ -110,6 +126,9 @@ __all__ = [
"get_activity_intervals",
"get_activity_messages",
"get_activity_streams",
"search_activities",
"get_activity_best_efforts",
"get_activity_interval_stats",
"get_events",
"get_event_by_id",
"delete_event",
@@ -117,6 +136,14 @@ __all__ = [
"add_or_update_event",
"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_custom_items",
@@ -10,9 +10,12 @@ from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
# Note: Tools register themselves via @mcp.tool() decorators when imported
from intervals_mcp_server.tools.activities import ( # noqa: F401
get_activities,
get_activity_best_efforts,
get_activity_details,
get_activity_interval_stats,
get_activity_intervals,
get_activity_streams,
search_activities,
)
from intervals_mcp_server.tools.events import ( # noqa: F401
add_or_update_event,
@@ -33,9 +36,18 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
)
from intervals_mcp_server.tools.gear import get_gear_list # 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:
@@ -60,6 +72,9 @@ __all__ = [
"get_activity_details",
"get_activity_intervals",
"get_activity_streams",
"search_activities",
"get_activity_best_efforts",
"get_activity_interval_stats",
"get_events",
"get_event_by_id",
"delete_event",
@@ -74,4 +89,12 @@ __all__ = [
"get_gear_list",
"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_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
# Import mcp instance from shared module for tool registration
@@ -405,3 +412,110 @@ async def add_activity_message(
if msg_id is not None:
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."
@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)
+184 -30
View File
@@ -4,18 +4,60 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from datetime import datetime
from datetime import datetime, timedelta
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.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
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()
async def get_wellness_data(
@@ -136,35 +178,29 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
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}
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."
@@ -187,3 +223,121 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
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)
@@ -420,6 +420,146 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
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:
"""Format a basic event summary into a readable string."""
@@ -676,3 +816,163 @@ def format_power_curves(
lines.append("")
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)
+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
+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())
+37 -5
View File
@@ -11,16 +11,28 @@ import pytest
from intervals_mcp_server import credentials
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_CALLS = [
TOOL_CALLS: list[tuple] = [
(activities.get_activities, ()),
(activities.get_activity_details, ("1",)),
(activities.get_activity_intervals, ("1",)),
(activities.get_activity_streams, ("1",)),
(activities.get_activity_messages, ("1",)),
(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_event_by_id, ("e1",)),
(events.delete_event, ("e1",)),
@@ -28,6 +40,15 @@ TOOL_CALLS = [
(events.add_or_update_event, ("Ride", "Name")),
(events.add_or_update_note, ("Name", "desc")),
(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, ()),
(gear.get_gear_list, ()),
(custom_items.get_custom_items, ()),
@@ -50,6 +71,17 @@ def test_tool_returns_message_when_unauthorized(monkeypatch, func, args):
assert result == "ACCOUNT NOT APPROVED"
def test_all_20_tools_covered():
"""Guard: if a tool is added, add it here so its auth gate is tested."""
assert len(TOOL_CALLS) == 20
def test_all_tools_covered():
"""Guard: every registered MCP tool must appear in TOOL_CALLS.
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)}"
)
+99
View File
@@ -134,3 +134,102 @@ def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
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]]
name = "intervalsicu-mcp"
version = "0.1.0"
version = "0.3.0"
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 = [