feat(multi-tenant): resolve per-caller credentials in every tool
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 42s

All 20 tools now drop the athlete_id/api_key parameters and instead resolve the
authenticated caller's stored, enabled credentials via
credentials.resolve_caller_credentials() (get_access_token().subject -> store).

Security: there is no tool parameter a caller can pass to supply a key, so a
disabled/unapproved user cannot bypass the admin-approval gate — each tool
returns a helpful "not approved / set up your credentials" message instead.
Gear resolution now uses the caller's athlete id rather than an env var.

Tests: conftest autouse fixture runs tool tests as an enabled user; a
parametrized test asserts every tool refuses when unauthorized; existing tool
tests updated (no more athlete_id/api_key kwargs). 221 passing at 91.5%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:28:27 -04:00
parent 31eb45c3f8
commit f067f9639a
14 changed files with 256 additions and 195 deletions
+3
View File
@@ -32,7 +32,10 @@ class CredentialError(Exception):
async def resolve_caller_credentials() -> tuple[str, str]: async def resolve_caller_credentials() -> tuple[str, str]:
"""Return ``(athlete_id, api_key)`` for the current caller or raise CredentialError.""" """Return ``(athlete_id, api_key)`` for the current caller or raise CredentialError."""
try:
token = get_access_token() token = get_access_token()
except Exception: # noqa: BLE001 - no auth context (stdio/local dev)
token = None
if token is not None and token.subject: if token is not None and token.subject:
creds = await store.get_active_credentials(token.subject) creds = await store.get_active_credentials(token.subject)
if creds is None: if creds is None:
+39 -26
View File
@@ -7,20 +7,19 @@ This module contains tools for retrieving and managing athlete activities.
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools.gear import ( from intervals_mcp_server.tools.gear import (
resolve_gear_for_activity, resolve_gear_for_activity,
resolve_gear_for_activities, resolve_gear_for_activities,
) )
from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params from intervals_mcp_server.utils.validation import resolve_date_params
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]: def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]:
"""Extract a list of activity dictionaries from the API result.""" """Extract a list of activity dictionaries from the API result."""
@@ -105,28 +104,24 @@ def _format_activities_response(
@mcp.tool() @mcp.tool()
async def get_activities( # pylint: disable=too-many-arguments,too-many-return-statements,too-many-branches,too-many-positional-arguments async def get_activities( # pylint: disable=too-many-return-statements,too-many-branches
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
limit: int = 10, limit: int = 10,
include_unnamed: bool = False, include_unnamed: bool = False,
) -> str: ) -> str:
"""Get a list of activities for an athlete from Intervals.icu """Get a list of activities for the signed-in athlete from Intervals.icu
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) 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) end_date: End date in YYYY-MM-DD format (optional, defaults to today)
limit: Maximum number of activities to return (optional, defaults to 10) limit: Maximum number of activities to return (optional, defaults to 10)
include_unnamed: Whether to include unnamed activities (optional, defaults to False) include_unnamed: Whether to include unnamed activities (optional, defaults to False)
""" """
# Resolve athlete ID and date parameters try:
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
if error_msg: except CredentialError as exc:
return error_msg return str(exc)
start_date, end_date = resolve_date_params(start_date, end_date) start_date, end_date = resolve_date_params(start_date, end_date)
@@ -176,13 +171,17 @@ async def get_activities( # pylint: disable=too-many-arguments,too-many-return-
@mcp.tool() @mcp.tool()
async def get_activity_details(activity_id: str, api_key: str | None = None) -> str: async def get_activity_details(activity_id: str) -> str:
"""Get detailed information for a specific activity from Intervals.icu """Get detailed information for a specific activity from Intervals.icu
Args: Args:
activity_id: The Intervals.icu activity ID activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
# Call the Intervals.icu API # Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key) result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key)
@@ -199,8 +198,8 @@ async def get_activity_details(activity_id: str, api_key: str | None = None) ->
if not isinstance(activity_data, dict): if not isinstance(activity_data, dict):
return f"Invalid activity format for activity {activity_id}." return f"Invalid activity format for activity {activity_id}."
# Resolve gear name (uses configured athlete_id via ATHLETE_ID env var) # Resolve gear name for the signed-in athlete
await resolve_gear_for_activity(activity_data, api_key=api_key) await resolve_gear_for_activity(activity_data, athlete_id=athlete_id_to_use, api_key=api_key)
# Return a more detailed view of the activity # Return a more detailed view of the activity
detailed_view = format_activity_summary(activity_data) detailed_view = format_activity_summary(activity_data)
@@ -220,7 +219,7 @@ async def get_activity_details(activity_id: str, api_key: str | None = None) ->
@mcp.tool() @mcp.tool()
async def get_activity_intervals(activity_id: str, api_key: str | None = None) -> str: async def get_activity_intervals(activity_id: str) -> str:
"""Get interval data for a specific activity from Intervals.icu """Get interval data for a specific activity from Intervals.icu
This endpoint returns detailed metrics for each interval in an activity, including power, heart rate, This endpoint returns detailed metrics for each interval in an activity, including power, heart rate,
@@ -228,8 +227,12 @@ async def get_activity_intervals(activity_id: str, api_key: str | None = None) -
Args: Args:
activity_id: The Intervals.icu activity ID activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
# Call the Intervals.icu API # Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key) result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key)
@@ -254,7 +257,6 @@ async def get_activity_intervals(activity_id: str, api_key: str | None = None) -
@mcp.tool() @mcp.tool()
async def get_activity_streams( async def get_activity_streams(
activity_id: str, activity_id: str,
api_key: str | None = None,
stream_types: str | None = None, stream_types: str | None = None,
) -> str: ) -> str:
"""Get stream data for a specific activity from Intervals.icu """Get stream data for a specific activity from Intervals.icu
@@ -264,11 +266,15 @@ async def get_activity_streams(
Args: Args:
activity_id: The Intervals.icu activity ID activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
stream_types: Comma-separated list of stream types to retrieve (optional, defaults to all available types) stream_types: Comma-separated list of stream types to retrieve (optional, defaults to all available types)
Available types: time, watts, heartrate, cadence, altitude, distance, Available types: time, watts, heartrate, cadence, altitude, distance,
core_temperature, skin_temperature, velocity_smooth core_temperature, skin_temperature, velocity_smooth
""" """
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
# Build query parameters # Build query parameters
params = {} params = {}
if stream_types: if stream_types:
@@ -330,13 +336,17 @@ async def get_activity_streams(
@mcp.tool() @mcp.tool()
async def get_activity_messages(activity_id: str, api_key: str | None = None) -> str: async def get_activity_messages(activity_id: str) -> str:
"""Get messages (notes/comments) for a specific activity from Intervals.icu """Get messages (notes/comments) for a specific activity from Intervals.icu
Args: Args:
activity_id: The Intervals.icu activity ID activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request( result = await make_intervals_request(
url=f"/activity/{activity_id}/messages", url=f"/activity/{activity_id}/messages",
api_key=api_key, api_key=api_key,
@@ -365,15 +375,18 @@ async def get_activity_messages(activity_id: str, api_key: str | None = None) ->
async def add_activity_message( async def add_activity_message(
activity_id: str, activity_id: str,
content: str, content: str,
api_key: str | None = None,
) -> str: ) -> str:
"""Add a message (note/comment) to an activity on Intervals.icu """Add a message (note/comment) to an activity on Intervals.icu
Args: Args:
activity_id: The Intervals.icu activity ID activity_id: The Intervals.icu activity ID
content: The message text to add content: The message text to add
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
try:
_athlete_id, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
result = await make_intervals_request( result = await make_intervals_request(
url=f"/activity/{activity_id}/messages", url=f"/activity/{activity_id}/messages",
api_key=api_key, api_key=api_key,
+22 -39
View File
@@ -7,31 +7,26 @@ This module contains tools for managing athlete custom items (charts, fields, zo
import json import json
from typing import Any from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_custom_item_details from intervals_mcp_server.utils.formatting import format_custom_item_details
from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool() @mcp.tool()
async def get_custom_items( async def get_custom_items(
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu """Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
result = await make_intervals_request( result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key
@@ -58,19 +53,16 @@ async def get_custom_items(
@mcp.tool() @mcp.tool()
async def get_custom_item_by_id( async def get_custom_item_by_id(
item_id: int, item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Get detailed information for a specific custom item from Intervals.icu """Get detailed information for a specific custom item from Intervals.icu
Args: Args:
item_id: The custom item ID item_id: The custom item ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
result = await make_intervals_request( result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key
@@ -89,8 +81,6 @@ async def get_custom_item_by_id(
async def create_custom_item( async def create_custom_item(
name: str, name: str,
item_type: str, item_type: str,
athlete_id: str | None = None,
api_key: str | None = None,
description: str | None = None, description: str | None = None,
content: dict[str, Any] | None = None, content: dict[str, Any] | None = None,
visibility: str | None = None, visibility: str | None = None,
@@ -100,17 +90,16 @@ async def create_custom_item(
Args: Args:
name: Name of the custom item name: Name of the custom item
item_type: Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES) item_type: Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES)
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
description: Description of the custom item (optional) description: Description of the custom item (optional)
content: Configuration content for the custom item as a dict (optional). Important enum values: content: Configuration content for the custom item as a dict (optional). Important enum values:
- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number") - "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG") - "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
data: dict[str, Any] = {"name": name, "type": item_type} data: dict[str, Any] = {"name": name, "type": item_type}
if description is not None: if description is not None:
@@ -144,8 +133,6 @@ async def create_custom_item(
@mcp.tool() @mcp.tool()
async def update_custom_item( async def update_custom_item(
item_id: int, item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
name: str | None = None, name: str | None = None,
item_type: str | None = None, item_type: str | None = None,
description: str | None = None, description: str | None = None,
@@ -156,8 +143,6 @@ async def update_custom_item(
Args: Args:
item_id: The custom item ID to update item_id: The custom item ID to update
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
name: New name for the custom item (optional) name: New name for the custom item (optional)
item_type: New type for the custom item (optional) item_type: New type for the custom item (optional)
description: New description for the custom item (optional) description: New description for the custom item (optional)
@@ -166,9 +151,10 @@ async def update_custom_item(
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG") - "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
data: dict[str, Any] = {} data: dict[str, Any] = {}
if name is not None: if name is not None:
@@ -206,19 +192,16 @@ async def update_custom_item(
@mcp.tool() @mcp.tool()
async def delete_custom_item( async def delete_custom_item(
item_id: int, item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Delete a custom item for an athlete from Intervals.icu """Delete a custom item for an athlete from Intervals.icu
Args: Args:
item_id: The custom item ID to delete item_id: The custom item ID to delete
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
result = await make_intervals_request( result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
+27 -44
View File
@@ -8,18 +8,17 @@ import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.dates import get_default_end_date, get_default_future_end_date from intervals_mcp_server.utils.dates import get_default_end_date, get_default_future_end_date
from intervals_mcp_server.utils.formatting import format_event_details, format_event_summary from intervals_mcp_server.utils.formatting import format_event_details, format_event_summary
from intervals_mcp_server.utils.types import WorkoutDoc from intervals_mcp_server.utils.types import WorkoutDoc
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id, validate_date from intervals_mcp_server.utils.validation import resolve_activity_type, validate_date
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
def _prepare_event_data( # pylint: disable=too-many-arguments,too-many-positional-arguments def _prepare_event_data( # pylint: disable=too-many-arguments,too-many-positional-arguments
name: str, name: str,
@@ -89,23 +88,20 @@ async def _delete_events_list(
@mcp.tool() @mcp.tool()
async def get_events( async def get_events(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
) -> str: ) -> str:
"""Get events for an athlete from Intervals.icu """Get events for an athlete from Intervals.icu
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today) start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today) end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today)
""" """
# Resolve athlete ID # Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
# Parse date parameters (events use different defaults) # Parse date parameters (events use different defaults)
if not start_date: if not start_date:
@@ -147,20 +143,17 @@ async def get_events(
@mcp.tool() @mcp.tool()
async def get_event_by_id( async def get_event_by_id(
event_id: str, event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Get detailed information for a specific event from Intervals.icu """Get detailed information for a specific event from Intervals.icu
Args: Args:
event_id: The Intervals.icu event ID event_id: The Intervals.icu event ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
""" """
# Resolve athlete ID # Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
# Call the Intervals.icu API # Call the Intervals.icu API
result = await make_intervals_request( result = await make_intervals_request(
@@ -184,18 +177,15 @@ async def get_event_by_id(
@mcp.tool() @mcp.tool()
async def delete_event( async def delete_event(
event_id: str, event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Delete event for an athlete from Intervals.icu """Delete event for an athlete from Intervals.icu
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID event_id: The Intervals.icu event ID
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
if not event_id: if not event_id:
return "Error: No event ID provided." return "Error: No event ID provided."
result = await make_intervals_request( result = await make_intervals_request(
@@ -234,20 +224,17 @@ async def _fetch_events_for_deletion(
async def delete_events_by_date_range( async def delete_events_by_date_range(
start_date: str, start_date: str,
end_date: str, end_date: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Delete events for an athlete from Intervals.icu in the specified date range. """Delete events for an athlete from Intervals.icu in the specified date range.
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format end_date: End date in YYYY-MM-DD format
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
events, error_msg = await _fetch_events_for_deletion( events, error_msg = await _fetch_events_for_deletion(
athlete_id_to_use, api_key, start_date, end_date athlete_id_to_use, api_key, start_date, end_date
@@ -264,8 +251,6 @@ async def delete_events_by_date_range(
async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-positional-arguments async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-positional-arguments
workout_type: str, workout_type: str,
name: str, name: str,
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None, event_id: str | None = None,
start_date: str | None = None, start_date: str | None = None,
workout_doc: WorkoutDoc | None = None, workout_doc: WorkoutDoc | None = None,
@@ -278,8 +263,6 @@ async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-po
Many arguments are required as this MCP tool function maps directly to the Intervals.icu API parameters. Many arguments are required as this MCP tool function maps directly to the Intervals.icu API parameters.
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID (optional, will use event_id from .env if not provided) event_id: The Intervals.icu event ID (optional, will use event_id from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today) start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
name: Name of the activity name: Name of the activity
@@ -337,9 +320,10 @@ async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-po
- Use "reps" with nested steps to define repeat intervals (as in example above) - Use "reps" with nested steps to define repeat intervals (as in example above)
- Define one of "power", "hr" or "pace" to define step intensity - Define one of "power", "hr" or "pace" to define step intensity
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
if not start_date: if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d") start_date = datetime.now().strftime("%Y-%m-%d")
@@ -362,8 +346,6 @@ async def add_or_update_note(
description: str, description: str,
start_date: str | None = None, start_date: str | None = None,
color: str | None = "green", color: str | None = "green",
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None, event_id: str | None = None,
) -> str: ) -> str:
"""Add or update a plain text note (category NOTE) on the Intervals.icu calendar. """Add or update a plain text note (category NOTE) on the Intervals.icu calendar.
@@ -377,9 +359,10 @@ async def add_or_update_note(
api_key: The Intervals.icu API key (optional) api_key: The Intervals.icu API key (optional)
event_id: The Intervals.icu event ID (optional, for updates) event_id: The Intervals.icu event ID (optional, for updates)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
if not start_date: if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d") start_date = datetime.now().strftime("%Y-%m-%d")
+7 -10
View File
@@ -19,8 +19,10 @@ Call `get_gear_list(refresh=True)` to bust the cache.
from typing import Any from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.config import get_config
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.validation import resolve_athlete_id from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
@@ -151,26 +153,21 @@ async def resolve_gear_for_activities(
@mcp.tool() @mcp.tool()
async def get_gear_list( async def get_gear_list(
athlete_id: str | None = None,
api_key: str | None = None,
refresh: bool = False, refresh: bool = False,
) -> str: ) -> str:
"""Get the gear catalog (bikes, shoes, etc.) for an athlete from Intervals.icu. """Get the gear catalog (bikes, shoes, etc.) for the signed-in athlete from Intervals.icu.
Returns one line per gear item with id, type, name, and basic stats. Returns one line per gear item with id, type, name, and basic stats.
The result is cached for the MCP process lifetime; pass refresh=True to The result is cached for the MCP process lifetime; pass refresh=True to
re-fetch. re-fetch.
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
refresh: If True, bypass the cache and re-fetch from the API (default False) refresh: If True, bypass the cache and re-fetch from the API (default False)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
if not athlete_id_to_use: return str(exc)
return "Error: athlete_id is required (either as argument or via ATHLETE_ID env var)."
# Single fetch path: get_gear_raw consults the cache and only hits the API # Single fetch path: get_gear_raw consults the cache and only hits the API
# on a cold cache or when refresh=True. # on a cold cache or when refresh=True.
+6 -11
View File
@@ -8,16 +8,14 @@ import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_power_curves from intervals_mcp_server.utils.formatting import format_power_curves
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
# 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min # 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min
DEFAULT_DURATIONS: tuple[int, ...] = (5, 15, 30, 60, 120, 300, 600, 1200, 3600) DEFAULT_DURATIONS: tuple[int, ...] = (5, 15, 30, 60, 120, 300, 600, 1200, 3600)
@@ -135,8 +133,6 @@ async def get_athlete_power_curves(
this_season: bool = True, this_season: bool = True,
last_season: bool = True, last_season: bool = True,
include_normalised: bool = True, include_normalised: bool = True,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str: ) -> str:
"""Get power curves for an athlete from Intervals.icu. """Get power curves for an athlete from Intervals.icu.
@@ -152,15 +148,14 @@ async def get_athlete_power_curves(
this_season: Include this season's curve (default True) this_season: Include this season's curve (default True)
last_season: Include last season's curve (default True) last_season: Include last season's curve (default True)
include_normalised: Include weight-normalised W/kg values (default True) include_normalised: Include weight-normalised W/kg values (default True)
athlete_id: Intervals.icu athlete ID (optional, uses ATHLETE_ID from .env if not provided)
api_key: Optional API key override. Uses API_KEY from .env if not provided.
""" """
if durations is None: if durations is None:
durations = list(DEFAULT_DURATIONS) durations = list(DEFAULT_DURATIONS)
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"): if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"):
return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted." return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted."
+8 -12
View File
@@ -4,41 +4,37 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data. This module contains tools for retrieving athlete wellness data.
""" """
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_wellness_entry from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params from intervals_mcp_server.utils.validation import resolve_date_params
# Import mcp instance from shared module for tool registration # Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401 from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool() @mcp.tool()
async def get_wellness_data( async def get_wellness_data(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
include_all_fields: bool = False, include_all_fields: bool = False,
) -> str: ) -> str:
"""Get wellness data for an athlete from Intervals.icu. """Get wellness data for the signed-in athlete from Intervals.icu.
By default returns standard wellness fields (training metrics, vitals, sleep, By default returns standard wellness fields (training metrics, vitals, sleep,
subjective scores, etc.). Set include_all_fields=True to also include any subjective scores, etc.). Set include_all_fields=True to also include any
additional or custom fields configured by the user in Intervals.icu. additional or custom fields configured by the user in Intervals.icu.
Args: Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) 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) end_date: End date in YYYY-MM-DD format (optional, defaults to today)
include_all_fields: If True, include additional and custom fields beyond the standard set (optional, defaults to False) include_all_fields: If True, include additional and custom fields beyond the standard set (optional, defaults to False)
""" """
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) try:
if error_msg: athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
return error_msg except CredentialError as exc:
return str(exc)
start_date, end_date = resolve_date_params(start_date, end_date) start_date, end_date = resolve_date_params(start_date, end_date)
+22
View File
@@ -0,0 +1,22 @@
"""Shared test fixtures."""
import pytest
from intervals_mcp_server import credentials
@pytest.fixture(autouse=True)
def _default_caller_credentials(monkeypatch):
"""Run tool tests as an enabled user with fixed credentials.
Tools resolve the caller via ``credentials.resolve_caller_credentials()``;
patching the module attribute covers every tool at once. A test can override
this (e.g. patch it to raise ``CredentialError``) to exercise the not-approved
path. Tests that exercise the resolver itself import the function directly and
are unaffected.
"""
async def _creds():
return ("i1", "testkey")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _creds)
+3 -3
View File
@@ -75,7 +75,7 @@ def test_format_response_empty_named_hint():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_get_activities_error(monkeypatch): def test_get_activities_error(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "rate limited"}) _patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "rate limited"})
out = asyncio.run(activities.get_activities(athlete_id="i1")) out = asyncio.run(activities.get_activities())
assert "Error fetching activities: rate limited" in out assert "Error fetching activities: rate limited" in out
@@ -87,7 +87,7 @@ def test_get_activities_requests_triple_limit_and_filters(monkeypatch):
calls = _patch_request(monkeypatch, handler) calls = _patch_request(monkeypatch, handler)
out = asyncio.run( out = asyncio.run(
activities.get_activities(athlete_id="i1", start_date="2026-06-01", end_date="2026-06-30", limit=5) activities.get_activities(start_date="2026-06-01", end_date="2026-06-30", limit=5)
) )
assert calls[0]["params"]["limit"] == 15 # limit * 3 when filtering unnamed assert calls[0]["params"]["limit"] == 15 # limit * 3 when filtering unnamed
assert len(calls) == 2 # topped up because named < limit assert len(calls) == 2 # topped up because named < limit
@@ -97,7 +97,7 @@ def test_get_activities_requests_triple_limit_and_filters(monkeypatch):
def test_get_activities_include_unnamed_no_topup(monkeypatch): def test_get_activities_include_unnamed_no_topup(monkeypatch):
calls = _patch_request(monkeypatch, lambda _n, _k: [{"name": "Unnamed", "id": 1, "distance": 5}]) calls = _patch_request(monkeypatch, lambda _n, _k: [{"name": "Unnamed", "id": 1, "distance": 5}])
out = asyncio.run(activities.get_activities(athlete_id="i1", include_unnamed=True, limit=10)) out = asyncio.run(activities.get_activities(include_unnamed=True, limit=10))
assert calls[0]["params"]["limit"] == 10 # no *3 assert calls[0]["params"]["limit"] == 10 # no *3
assert len(calls) == 1 # no fetch-more assert len(calls) == 1 # no fetch-more
assert "Activities:" in out assert "Activities:" in out
+14
View File
@@ -51,6 +51,20 @@ def test_no_token_falls_back_to_env_config(monkeypatch):
assert asyncio.run(resolve_caller_credentials()) == ("i999", "envkey") assert asyncio.run(resolve_caller_credentials()) == ("i999", "envkey")
def test_get_access_token_raising_is_treated_as_no_context(monkeypatch):
# outside a request the SDK accessor may raise; that must fall back to env config
def _boom():
raise RuntimeError("no request context")
monkeypatch.setattr(credentials, "get_access_token", _boom)
monkeypatch.setattr(
credentials,
"get_config",
lambda: Config(api_key="envkey", athlete_id="i999", intervals_api_base_url="x", user_agent="t"),
)
assert asyncio.run(resolve_caller_credentials()) == ("i999", "envkey")
def test_no_token_no_env_raises(monkeypatch): def test_no_token_no_env_raises(monkeypatch):
monkeypatch.setattr(credentials, "get_access_token", lambda: None) monkeypatch.setattr(credentials, "get_access_token", lambda: None)
monkeypatch.setattr( monkeypatch.setattr(
+11 -11
View File
@@ -38,19 +38,19 @@ def _run(coro):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_get_custom_items_lists(monkeypatch): def test_get_custom_items_lists(monkeypatch):
_patch(monkeypatch, lambda _k: [{"id": 1, "name": "Zones", "type": "ZONES", "description": "d"}]) _patch(monkeypatch, lambda _k: [{"id": 1, "name": "Zones", "type": "ZONES", "description": "d"}])
out = _run(custom_items.get_custom_items(athlete_id="i1")) out = _run(custom_items.get_custom_items())
assert "Custom Items:" in out assert "Custom Items:" in out
assert "ID: 1" in out and "Name: Zones" in out and "Type: ZONES" in out assert "ID: 1" in out and "Name: Zones" in out and "Type: ZONES" in out
def test_get_custom_items_error(monkeypatch): def test_get_custom_items_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
assert "Error fetching custom items: boom" in _run(custom_items.get_custom_items(athlete_id="i1")) assert "Error fetching custom items: boom" in _run(custom_items.get_custom_items())
def test_get_custom_item_by_id_not_found(monkeypatch): def test_get_custom_item_by_id_not_found(monkeypatch):
_patch(monkeypatch, lambda _k: []) # falsy / not a dict _patch(monkeypatch, lambda _k: []) # falsy / not a dict
assert "No custom item found with ID 7" in _run(custom_items.get_custom_item_by_id(7, athlete_id="i1")) assert "No custom item found with ID 7" in _run(custom_items.get_custom_item_by_id(7))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -60,7 +60,7 @@ def test_create_builds_full_payload(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"}) rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
out = _run( out = _run(
custom_items.create_custom_item( custom_items.create_custom_item(
name="Chart", item_type="FITNESS_CHART", athlete_id="i1", name="Chart", item_type="FITNESS_CHART",
description="desc", content={"a": 1}, visibility="PRIVATE", description="desc", content={"a": 1}, visibility="PRIVATE",
) )
) )
@@ -78,7 +78,7 @@ def test_create_parses_json_string_content(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 1, "name": "X", "type": "ZONES"}) rec = _patch(monkeypatch, lambda _k: {"id": 1, "name": "X", "type": "ZONES"})
_run( _run(
custom_items.create_custom_item( custom_items.create_custom_item(
name="X", item_type="ZONES", athlete_id="i1", content='{"expression": "icu_training_load"}' name="X", item_type="ZONES", content='{"expression": "icu_training_load"}'
) )
) )
assert rec.calls[0]["data"]["content"] == {"expression": "icu_training_load"} # parsed to dict assert rec.calls[0]["data"]["content"] == {"expression": "icu_training_load"} # parsed to dict
@@ -87,7 +87,7 @@ def test_create_parses_json_string_content(monkeypatch):
def test_create_rejects_invalid_json_string(monkeypatch): def test_create_rejects_invalid_json_string(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 1}) rec = _patch(monkeypatch, lambda _k: {"id": 1})
out = _run( out = _run(
custom_items.create_custom_item(name="X", item_type="ZONES", athlete_id="i1", content="{not json") custom_items.create_custom_item(name="X", item_type="ZONES", content="{not json")
) )
assert "content must be valid JSON" in out assert "content must be valid JSON" in out
assert rec.calls == [] # bailed before any request assert rec.calls == [] # bailed before any request
@@ -95,7 +95,7 @@ def test_create_rejects_invalid_json_string(monkeypatch):
def test_create_error_surfaced(monkeypatch): def test_create_error_surfaced(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "bad type"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "bad type"})
out = _run(custom_items.create_custom_item(name="X", item_type="NOPE", athlete_id="i1")) out = _run(custom_items.create_custom_item(name="X", item_type="NOPE"))
assert "Error creating custom item: bad type" in out assert "Error creating custom item: bad type" in out
@@ -104,7 +104,7 @@ def test_create_error_surfaced(monkeypatch):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_update_sends_only_provided_fields_via_put(monkeypatch): def test_update_sends_only_provided_fields_via_put(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 5, "name": "New", "type": "ZONES"}) rec = _patch(monkeypatch, lambda _k: {"id": 5, "name": "New", "type": "ZONES"})
_run(custom_items.update_custom_item(item_id=5, athlete_id="i1", name="New")) _run(custom_items.update_custom_item(item_id=5, name="New"))
call = rec.calls[0] call = rec.calls[0]
assert call["method"] == "PUT" assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/custom-item/5" assert call["url"] == "/athlete/i1/custom-item/5"
@@ -113,7 +113,7 @@ def test_update_sends_only_provided_fields_via_put(monkeypatch):
def test_update_invalid_json_string(monkeypatch): def test_update_invalid_json_string(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 5}) rec = _patch(monkeypatch, lambda _k: {"id": 5})
out = _run(custom_items.update_custom_item(item_id=5, athlete_id="i1", content="{bad")) out = _run(custom_items.update_custom_item(item_id=5, content="{bad"))
assert "content must be valid JSON" in out assert "content must be valid JSON" in out
assert rec.calls == [] assert rec.calls == []
@@ -123,7 +123,7 @@ def test_update_invalid_json_string(monkeypatch):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_delete_uses_delete_method(monkeypatch): def test_delete_uses_delete_method(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {}) rec = _patch(monkeypatch, lambda _k: {})
out = _run(custom_items.delete_custom_item(item_id=3, athlete_id="i1")) out = _run(custom_items.delete_custom_item(item_id=3))
call = rec.calls[0] call = rec.calls[0]
assert call["method"] == "DELETE" assert call["method"] == "DELETE"
assert call["url"] == "/athlete/i1/custom-item/3" assert call["url"] == "/athlete/i1/custom-item/3"
@@ -132,4 +132,4 @@ def test_delete_uses_delete_method(monkeypatch):
def test_delete_error(monkeypatch): def test_delete_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "locked"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "locked"})
assert "Error deleting custom item: locked" in _run(custom_items.delete_custom_item(item_id=3, athlete_id="i1")) assert "Error deleting custom item: locked" in _run(custom_items.delete_custom_item(item_id=3))
+13 -13
View File
@@ -74,7 +74,7 @@ def test_handle_event_response_branches(result, action, needle):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_get_events_sends_date_params_and_formats(monkeypatch): def test_get_events_sends_date_params_and_formats(monkeypatch):
rec = _patch(monkeypatch, lambda _k: [{"start_date_local": "2026-07-10", "id": "e1", "name": "Race", "race": True}]) rec = _patch(monkeypatch, lambda _k: [{"start_date_local": "2026-07-10", "id": "e1", "name": "Race", "race": True}])
out = _run(events.get_events(athlete_id="i1", start_date="2026-07-01", end_date="2026-07-31")) out = _run(events.get_events(start_date="2026-07-01", end_date="2026-07-31"))
call = rec.calls[0] call = rec.calls[0]
assert call["url"] == "/athlete/i1/events" assert call["url"] == "/athlete/i1/events"
assert call["params"] == {"oldest": "2026-07-01", "newest": "2026-07-31"} assert call["params"] == {"oldest": "2026-07-01", "newest": "2026-07-31"}
@@ -83,25 +83,25 @@ def test_get_events_sends_date_params_and_formats(monkeypatch):
def test_get_events_error_surfaced(monkeypatch): def test_get_events_error_surfaced(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "rate limited"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "rate limited"})
out = _run(events.get_events(athlete_id="i1")) out = _run(events.get_events())
assert "Error fetching events: rate limited" in out assert "Error fetching events: rate limited" in out
def test_get_events_empty(monkeypatch): def test_get_events_empty(monkeypatch):
_patch(monkeypatch, lambda _k: []) _patch(monkeypatch, lambda _k: [])
out = _run(events.get_events(athlete_id="i1")) out = _run(events.get_events())
assert "No events found" in out assert "No events found" in out
def test_get_event_by_id_invalid_format(monkeypatch): def test_get_event_by_id_invalid_format(monkeypatch):
_patch(monkeypatch, lambda _k: [1, 2, 3]) # list, not a dict _patch(monkeypatch, lambda _k: [1, 2, 3]) # list, not a dict
out = _run(events.get_event_by_id("e1", athlete_id="i1")) out = _run(events.get_event_by_id("e1"))
assert "Invalid event format" in out assert "Invalid event format" in out
def test_get_event_by_id_error(monkeypatch): def test_get_event_by_id_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "nope"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "nope"})
out = _run(events.get_event_by_id("e1", athlete_id="i1")) out = _run(events.get_event_by_id("e1"))
assert "Error fetching event details: nope" in out assert "Error fetching event details: nope" in out
@@ -112,7 +112,7 @@ def test_add_event_posts_when_no_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e99"}) rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
out = _run( out = _run(
events.add_or_update_event( events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1", workout_type="Ride", name="Threshold",
start_date="2026-07-10", moving_time=3600, distance=40000, start_date="2026-07-10", moving_time=3600, distance=40000,
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]), workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
) )
@@ -128,7 +128,7 @@ def test_update_event_puts_when_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e5"}) rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
_run( _run(
events.add_or_update_event( events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1", workout_type="Ride", name="Threshold",
event_id="e5", start_date="2026-07-10", event_id="e5", start_date="2026-07-10",
) )
) )
@@ -139,13 +139,13 @@ def test_update_event_puts_when_event_id(monkeypatch):
def test_add_event_invalid_date_returns_error(monkeypatch): def test_add_event_invalid_date_returns_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"id": "e1"}) _patch(monkeypatch, lambda _k: {"id": "e1"})
out = _run(events.add_or_update_event(workout_type="Ride", name="X", athlete_id="i1", start_date="07/10/2026")) out = _run(events.add_or_update_event(workout_type="Ride", name="X", start_date="07/10/2026"))
assert out.startswith("Error:") assert out.startswith("Error:")
def test_add_note_uses_note_category_and_color(monkeypatch): def test_add_note_uses_note_category_and_color(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "n1"}) rec = _patch(monkeypatch, lambda _k: {"id": "n1"})
_run(events.add_or_update_note(name="Sick day", description="rest", athlete_id="i1", start_date="2026-07-10", color="red")) _run(events.add_or_update_note(name="Sick day", description="rest", start_date="2026-07-10", color="red"))
data = rec.calls[0]["data"] data = rec.calls[0]["data"]
assert data["category"] == "NOTE" assert data["category"] == "NOTE"
assert data["color"] == "red" assert data["color"] == "red"
@@ -157,13 +157,13 @@ def test_add_note_uses_note_category_and_color(monkeypatch):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_delete_event_requires_id(monkeypatch): def test_delete_event_requires_id(monkeypatch):
_patch(monkeypatch, lambda _k: {}) _patch(monkeypatch, lambda _k: {})
out = _run(events.delete_event("", athlete_id="i1")) out = _run(events.delete_event(""))
assert "No event ID provided" in out assert "No event ID provided" in out
def test_delete_event_uses_delete_method(monkeypatch): def test_delete_event_uses_delete_method(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"deleted": True}) rec = _patch(monkeypatch, lambda _k: {"deleted": True})
_run(events.delete_event("e7", athlete_id="i1")) _run(events.delete_event("e7"))
call = rec.calls[0] call = rec.calls[0]
assert call["method"] == "DELETE" assert call["method"] == "DELETE"
assert call["url"] == "/athlete/i1/events/e7" assert call["url"] == "/athlete/i1/events/e7"
@@ -176,12 +176,12 @@ def test_delete_by_range_counts_successes_and_failures(monkeypatch):
return [{"id": 1}, {"id": 2}] # the GET listing return [{"id": 1}, {"id": 2}] # the GET listing
_patch(monkeypatch, handler) _patch(monkeypatch, handler)
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1")) out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31"))
assert "Deleted 1 events" in out assert "Deleted 1 events" in out
assert "Failed to delete 1 events: [2]" in out assert "Failed to delete 1 events: [2]" in out
def test_delete_by_range_fetch_error(monkeypatch): def test_delete_by_range_fetch_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"}) _patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1")) out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31"))
assert "Error deleting events: boom" in out assert "Error deleting events: boom" in out
+25 -25
View File
@@ -74,7 +74,7 @@ def test_get_activities(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request "intervals_mcp_server.tools.activities.make_intervals_request", fake_request
) )
result = asyncio.run(get_activities(athlete_id="1", limit=1, include_unnamed=True)) result = asyncio.run(get_activities(limit=1, include_unnamed=True))
assert "Morning Ride" in result assert "Morning Ride" in result
assert "Activities:" in result assert "Activities:" in result
@@ -122,7 +122,7 @@ def test_get_events(monkeypatch):
# Patch in both api.client and tools modules to ensure it works # Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
result = asyncio.run(get_events(athlete_id="1", start_date="2024-01-01", end_date="2024-01-02")) result = asyncio.run(get_events(start_date="2024-01-01", end_date="2024-01-02"))
assert "Test Event" in result assert "Test Event" in result
assert "Events:" in result assert "Events:" in result
@@ -145,7 +145,7 @@ def test_get_event_by_id(monkeypatch):
# Patch in both api.client and tools modules to ensure it works # Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
result = asyncio.run(get_event_by_id("e1", athlete_id="1")) result = asyncio.run(get_event_by_id("e1"))
assert "Event Details:" in result assert "Event Details:" in result
assert "Test Event" in result assert "Test Event" in result
@@ -168,7 +168,7 @@ def test_get_wellness_data(monkeypatch):
# Patch in both api.client and tools modules to ensure it works # Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1")) result = asyncio.run(get_wellness_data())
assert "Wellness Data:" in result assert "Wellness Data:" in result
assert "2024-01-01" in result assert "2024-01-01" in result
@@ -193,7 +193,7 @@ def test_get_wellness_data_renders_macros(monkeypatch):
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1")) result = asyncio.run(get_wellness_data())
assert "Wellness Data:" in result assert "Wellness Data:" in result
assert "2026-04-08" in result assert "2026-04-08" in result
assert "Nutrition & Hydration:" in result assert "Nutrition & Hydration:" in result
@@ -220,7 +220,7 @@ def test_get_wellness_data_include_all_fields(monkeypatch):
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1", include_all_fields=True)) result = asyncio.run(get_wellness_data(include_all_fields=True))
assert "Wellness Data:" in result assert "Wellness Data:" in result
assert "2024-01-01" in result assert "2024-01-01" in result
assert "Fitness (CTL): 75" in result assert "Fitness (CTL): 75" in result
@@ -321,7 +321,7 @@ def test_add_or_update_event(monkeypatch):
) )
result = asyncio.run( result = asyncio.run(
add_or_update_event( add_or_update_event(
athlete_id="i1", start_date="2024-01-15", name="Test Workout", workout_type="Ride" start_date="2024-01-15", name="Test Workout", workout_type="Ride"
) )
) )
assert "Successfully created event id:" in result assert "Successfully created event id:" in result
@@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch):
result = asyncio.run( result = asyncio.run(
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
athlete_id="i1",
) )
) )
assert "Power Curves (Ride):" in result assert "Power Curves (Ride):" in result
@@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
durations=[5, 60], durations=[5, 60],
athlete_id="i1",
) )
) )
assert "5s:" in result assert "5s:" in result
@@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
include_normalised=False, include_normalised=False,
athlete_id="i1",
) )
) )
assert "W/kg" not in result assert "W/kg" not in result
@@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch):
get_athlete_power_curves( get_athlete_power_curves(
activity_type="Ride", activity_type="Ride",
start_date="2026-01-01", start_date="2026-01-01",
athlete_id="i1",
) )
) )
assert "Error" in result assert "Error" in result
@@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
activity_type="Ride", activity_type="Ride",
this_season=False, this_season=False,
last_season=False, last_season=False,
athlete_id="i1",
) )
) )
assert "Error" in result assert "Error" in result
@@ -590,7 +590,7 @@ def test_get_custom_items(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
) )
result = asyncio.run(get_custom_items(athlete_id="1")) result = asyncio.run(get_custom_items())
assert "Custom Items:" in result assert "Custom Items:" in result
assert "HR Zones" in result assert "HR Zones" in result
assert "ZONES" in result assert "ZONES" in result
@@ -617,7 +617,7 @@ def test_get_custom_item_by_id(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
) )
result = asyncio.run(get_custom_item_by_id(item_id=1, athlete_id="1")) result = asyncio.run(get_custom_item_by_id(item_id=1))
assert "Custom Item Details:" in result assert "Custom Item Details:" in result
assert "HR Zones" in result assert "HR Zones" in result
assert "ZONES" in result assert "ZONES" in result
@@ -645,7 +645,7 @@ def test_create_custom_item(monkeypatch):
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
) )
result = asyncio.run( result = asyncio.run(
create_custom_item(name="New Chart", item_type="FITNESS_CHART", athlete_id="1") create_custom_item(name="New Chart", item_type="FITNESS_CHART")
) )
assert "Successfully created custom item:" in result assert "Successfully created custom item:" in result
assert "New Chart" in result assert "New Chart" in result
@@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch):
create_custom_item( create_custom_item(
name="Activity Field", name="Activity Field",
item_type="ACTIVITY_FIELD", item_type="ACTIVITY_FIELD",
athlete_id="1",
content='{"expression": "icu_training_load"}', # type: ignore[arg-type] content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
) )
) )
@@ -705,7 +705,7 @@ def test_update_custom_item(monkeypatch):
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
) )
result = asyncio.run( result = asyncio.run(
update_custom_item(item_id=1, name="Updated Chart", athlete_id="1") update_custom_item(item_id=1, name="Updated Chart")
) )
assert "Successfully updated custom item:" in result assert "Successfully updated custom item:" in result
assert "Updated Chart" in result assert "Updated Chart" in result
@@ -724,7 +724,7 @@ def test_delete_custom_item(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
) )
result = asyncio.run(delete_custom_item(item_id=1, athlete_id="1")) result = asyncio.run(delete_custom_item(item_id=1))
assert "Successfully deleted" in result assert "Successfully deleted" in result
@@ -744,7 +744,7 @@ def test_create_custom_item_with_invalid_json_content(monkeypatch):
create_custom_item( create_custom_item(
name="Bad Item", name="Bad Item",
item_type="FITNESS_CHART", item_type="FITNESS_CHART",
athlete_id="1",
content="not valid json", # type: ignore[arg-type] content="not valid json", # type: ignore[arg-type]
) )
) )
@@ -790,7 +790,7 @@ def test_get_gear_list(monkeypatch):
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request "intervals_mcp_server.tools.gear.make_intervals_request", fake_request
) )
result = asyncio.run(get_gear_list(athlete_id="i1")) result = asyncio.run(get_gear_list())
assert "Gear catalog for athlete i1:" in result assert "Gear catalog for athlete i1:" in result
assert "Litening Air" in result assert "Litening Air" in result
@@ -814,7 +814,7 @@ def test_get_gear_list_empty(monkeypatch):
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request "intervals_mcp_server.tools.gear.make_intervals_request", fake_request
) )
result = asyncio.run(get_gear_list(athlete_id="i1")) result = asyncio.run(get_gear_list())
assert "No gear found" in result assert "No gear found" in result
@@ -845,15 +845,15 @@ def test_get_gear_list_cache_and_refresh(monkeypatch):
) )
# First call: cache cold, one API hit expected. # First call: cache cold, one API hit expected.
asyncio.run(get_gear_list(athlete_id="i1")) asyncio.run(get_gear_list())
assert call_count["n"] == 1 assert call_count["n"] == 1
# Second call: cache warm, no additional API hit. # Second call: cache warm, no additional API hit.
asyncio.run(get_gear_list(athlete_id="i1")) asyncio.run(get_gear_list())
assert call_count["n"] == 1 assert call_count["n"] == 1
# refresh=True busts the cache and triggers a fresh fetch. # refresh=True busts the cache and triggers a fresh fetch.
asyncio.run(get_gear_list(athlete_id="i1", refresh=True)) asyncio.run(get_gear_list(refresh=True))
assert call_count["n"] == 2 assert call_count["n"] == 2
@@ -944,7 +944,7 @@ def test_get_activities_resolves_gear_name(monkeypatch):
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request "intervals_mcp_server.tools.gear.make_intervals_request", fake_request
) )
result = asyncio.run(get_activities(athlete_id="1", limit=2, include_unnamed=True)) result = asyncio.run(get_activities(limit=2, include_unnamed=True))
assert "Ride 1" in result assert "Ride 1" in result
assert "Ride 2" in result assert "Ride 2" in result
assert "Name: Litening Air" in result assert "Name: Litening Air" in result
+55
View File
@@ -0,0 +1,55 @@
"""
Every tool must refuse to act (and surface a helpful message) when the caller
has no usable credentials — a disabled/unapproved user, or one who hasn't set up
their Intervals.icu key. This guards the admin-approval gate: there is no tool
parameter a caller can pass to bypass it.
"""
import asyncio
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
# (tool callable, minimal required positional args)
TOOL_CALLS = [
(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")),
(events.get_events, ()),
(events.get_event_by_id, ("e1",)),
(events.delete_event, ("e1",)),
(events.delete_events_by_date_range, ("2026-07-01", "2026-07-31")),
(events.add_or_update_event, ("Ride", "Name")),
(events.add_or_update_note, ("Name", "desc")),
(wellness.get_wellness_data, ()),
(power_curves.get_athlete_power_curves, ()),
(gear.get_gear_list, ()),
(custom_items.get_custom_items, ()),
(custom_items.get_custom_item_by_id, (1,)),
(custom_items.create_custom_item, ("N", "TYPE")),
(custom_items.update_custom_item, (1,)),
(custom_items.delete_custom_item, (1,)),
]
async def _deny():
raise CredentialError("ACCOUNT NOT APPROVED")
@pytest.mark.parametrize("func,args", TOOL_CALLS, ids=[f.__name__ for f, _ in TOOL_CALLS])
def test_tool_returns_message_when_unauthorized(monkeypatch, func, args):
# override the autouse fixture: the caller has no usable credentials
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
result = asyncio.run(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