diff --git a/src/intervals_mcp_server/credentials.py b/src/intervals_mcp_server/credentials.py index 58124c5..8f66acc 100644 --- a/src/intervals_mcp_server/credentials.py +++ b/src/intervals_mcp_server/credentials.py @@ -32,7 +32,10 @@ class CredentialError(Exception): async def resolve_caller_credentials() -> tuple[str, str]: """Return ``(athlete_id, api_key)`` for the current caller or raise CredentialError.""" - token = get_access_token() + try: + token = get_access_token() + except Exception: # noqa: BLE001 - no auth context (stdio/local dev) + token = None if token is not None and token.subject: creds = await store.get_active_credentials(token.subject) if creds is None: diff --git a/src/intervals_mcp_server/tools/activities.py b/src/intervals_mcp_server/tools/activities.py index 0fa5187..5a54af6 100644 --- a/src/intervals_mcp_server/tools/activities.py +++ b/src/intervals_mcp_server/tools/activities.py @@ -7,20 +7,19 @@ This module contains tools for retrieving and managing athlete activities. 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.config import get_config +from intervals_mcp_server.credentials import CredentialError 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.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 from intervals_mcp_server.mcp_instance import mcp # noqa: F401 -config = get_config() - def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]: """Extract a list of activity dictionaries from the API result.""" @@ -105,28 +104,24 @@ def _format_activities_response( @mcp.tool() -async def get_activities( # pylint: disable=too-many-arguments,too-many-return-statements,too-many-branches,too-many-positional-arguments - athlete_id: str | None = None, - api_key: str | None = None, +async def get_activities( # pylint: disable=too-many-return-statements,too-many-branches start_date: str | None = None, end_date: str | None = None, limit: int = 10, include_unnamed: bool = False, ) -> 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: - 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) end_date: End date in YYYY-MM-DD format (optional, defaults to today) limit: Maximum number of activities to return (optional, defaults to 10) include_unnamed: Whether to include unnamed activities (optional, defaults to False) """ - # Resolve athlete ID and date parameters - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, 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) @@ -176,13 +171,17 @@ async def get_activities( # pylint: disable=too-many-arguments,too-many-return- @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 Args: 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 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): return f"Invalid activity format for activity {activity_id}." - # Resolve gear name (uses configured athlete_id via ATHLETE_ID env var) - await resolve_gear_for_activity(activity_data, api_key=api_key) + # Resolve gear name for the signed-in athlete + 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 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() -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 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: 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 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() async def get_activity_streams( activity_id: str, - api_key: str | None = None, stream_types: str | None = None, ) -> str: """Get stream data for a specific activity from Intervals.icu @@ -264,11 +266,15 @@ async def get_activity_streams( Args: 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) Available types: time, watts, heartrate, cadence, altitude, distance, 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 params = {} if stream_types: @@ -330,13 +336,17 @@ async def get_activity_streams( @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 Args: 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( url=f"/activity/{activity_id}/messages", 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( activity_id: str, content: str, - api_key: str | None = None, ) -> str: """Add a message (note/comment) to an activity on Intervals.icu Args: activity_id: The Intervals.icu activity ID 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( url=f"/activity/{activity_id}/messages", api_key=api_key, diff --git a/src/intervals_mcp_server/tools/custom_items.py b/src/intervals_mcp_server/tools/custom_items.py index 172d41c..3ef49dd 100644 --- a/src/intervals_mcp_server/tools/custom_items.py +++ b/src/intervals_mcp_server/tools/custom_items.py @@ -7,31 +7,26 @@ This module contains tools for managing athlete custom items (charts, fields, zo import json from typing import Any +from intervals_mcp_server import credentials 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.validation import resolve_athlete_id # Import mcp instance from shared module for tool registration from intervals_mcp_server.mcp_instance import mcp # noqa: F401 -config = get_config() - @mcp.tool() async def get_custom_items( - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu 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) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) result = await make_intervals_request( url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key @@ -58,19 +53,16 @@ async def get_custom_items( @mcp.tool() async def get_custom_item_by_id( item_id: int, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Get detailed information for a specific custom item from Intervals.icu Args: 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) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) result = await make_intervals_request( 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( name: str, item_type: str, - athlete_id: str | None = None, - api_key: str | None = None, description: str | None = None, content: dict[str, Any] | None = None, visibility: str | None = None, @@ -100,17 +90,16 @@ async def create_custom_item( Args: 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) - 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) 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") - "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG") visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) data: dict[str, Any] = {"name": name, "type": item_type} if description is not None: @@ -144,8 +133,6 @@ async def create_custom_item( @mcp.tool() async def update_custom_item( item_id: int, - athlete_id: str | None = None, - api_key: str | None = None, name: str | None = None, item_type: str | None = None, description: str | None = None, @@ -156,8 +143,6 @@ async def update_custom_item( Args: 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) item_type: New type 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") visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) data: dict[str, Any] = {} if name is not None: @@ -206,19 +192,16 @@ async def update_custom_item( @mcp.tool() async def delete_custom_item( item_id: int, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Delete a custom item for an athlete from Intervals.icu Args: 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) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) result = await make_intervals_request( url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", diff --git a/src/intervals_mcp_server/tools/events.py b/src/intervals_mcp_server/tools/events.py index 2ab93fe..94bdb5e 100644 --- a/src/intervals_mcp_server/tools/events.py +++ b/src/intervals_mcp_server/tools/events.py @@ -8,18 +8,17 @@ import json from datetime import datetime from typing import Any +from intervals_mcp_server import credentials from intervals_mcp_server.api.client import make_intervals_request -from intervals_mcp_server.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.formatting import format_event_details, format_event_summary 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 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 name: str, @@ -89,23 +88,20 @@ async def _delete_events_list( @mcp.tool() async def get_events( - athlete_id: str | None = None, - api_key: str | None = None, start_date: str | None = None, end_date: str | None = None, ) -> str: """Get events for an athlete from Intervals.icu 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) end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today) """ # Resolve athlete ID - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) # Parse date parameters (events use different defaults) if not start_date: @@ -147,20 +143,17 @@ async def get_events( @mcp.tool() async def get_event_by_id( event_id: str, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Get detailed information for a specific event from Intervals.icu Args: 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 - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) # Call the Intervals.icu API result = await make_intervals_request( @@ -184,18 +177,15 @@ async def get_event_by_id( @mcp.tool() async def delete_event( event_id: str, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Delete event for an athlete from Intervals.icu 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 """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) if not event_id: return "Error: No event ID provided." result = await make_intervals_request( @@ -234,20 +224,17 @@ async def _fetch_events_for_deletion( async def delete_events_by_date_range( start_date: str, end_date: str, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """Delete events for an athlete from Intervals.icu in the specified date range. 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 end_date: End date in YYYY-MM-DD format """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) events, error_msg = await _fetch_events_for_deletion( 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 workout_type: str, name: str, - athlete_id: str | None = None, - api_key: str | None = None, event_id: str | None = None, start_date: str | 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. 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) start_date: Start date in YYYY-MM-DD format (optional, defaults to today) 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) - 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) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) if not start_date: start_date = datetime.now().strftime("%Y-%m-%d") @@ -362,8 +346,6 @@ async def add_or_update_note( description: str, start_date: str | None = None, color: str | None = "green", - athlete_id: str | None = None, - api_key: str | None = None, event_id: str | None = None, ) -> str: """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) event_id: The Intervals.icu event ID (optional, for updates) """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) if not start_date: start_date = datetime.now().strftime("%Y-%m-%d") diff --git a/src/intervals_mcp_server/tools/gear.py b/src/intervals_mcp_server/tools/gear.py index adddde4..5816209 100644 --- a/src/intervals_mcp_server/tools/gear.py +++ b/src/intervals_mcp_server/tools/gear.py @@ -19,8 +19,10 @@ Call `get_gear_list(refresh=True)` to bust the cache. from typing import Any +from intervals_mcp_server import credentials 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.validation import resolve_athlete_id # Import mcp instance from shared module for tool registration @@ -151,26 +153,21 @@ async def resolve_gear_for_activities( @mcp.tool() async def get_gear_list( - athlete_id: str | None = None, - api_key: str | None = None, refresh: bool = False, ) -> 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. The result is cached for the MCP process lifetime; pass refresh=True to re-fetch. 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) """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg - if not athlete_id_to_use: - return "Error: athlete_id is required (either as argument or via ATHLETE_ID env var)." + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) # Single fetch path: get_gear_raw consults the cache and only hits the API # on a cold cache or when refresh=True. diff --git a/src/intervals_mcp_server/tools/power_curves.py b/src/intervals_mcp_server/tools/power_curves.py index c3ea268..14dcd21 100644 --- a/src/intervals_mcp_server/tools/power_curves.py +++ b/src/intervals_mcp_server/tools/power_curves.py @@ -8,16 +8,14 @@ import json from datetime import datetime from typing import Any +from intervals_mcp_server import credentials from intervals_mcp_server.api.client import make_intervals_request -from intervals_mcp_server.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.validation import resolve_activity_type, resolve_athlete_id # Import mcp instance from shared module for tool registration from intervals_mcp_server.mcp_instance import mcp # noqa: F401 -config = get_config() - # 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min 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, last_season: bool = True, include_normalised: bool = True, - athlete_id: str | None = None, - api_key: str | None = None, ) -> str: """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) last_season: Include last season's curve (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: durations = list(DEFAULT_DURATIONS) - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"): return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted." diff --git a/src/intervals_mcp_server/tools/wellness.py b/src/intervals_mcp_server/tools/wellness.py index 5ff5285..df99338 100644 --- a/src/intervals_mcp_server/tools/wellness.py +++ b/src/intervals_mcp_server/tools/wellness.py @@ -4,41 +4,37 @@ Wellness-related MCP tools for Intervals.icu. 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.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.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 from intervals_mcp_server.mcp_instance import mcp # noqa: F401 -config = get_config() - @mcp.tool() async def get_wellness_data( - athlete_id: str | None = None, - api_key: str | None = None, start_date: str | None = None, end_date: str | None = None, include_all_fields: bool = False, ) -> 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, subjective scores, etc.). Set include_all_fields=True to also include any additional or custom fields configured by the user in Intervals.icu. 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) 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) """ - athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) - if error_msg: - return error_msg + try: + athlete_id_to_use, 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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0cea35e --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_activities.py b/tests/test_activities.py index a02935a..5bd0fbd 100644 --- a/tests/test_activities.py +++ b/tests/test_activities.py @@ -75,7 +75,7 @@ def test_format_response_empty_named_hint(): # --------------------------------------------------------------------------- # def test_get_activities_error(monkeypatch): _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 @@ -87,7 +87,7 @@ def test_get_activities_requests_triple_limit_and_filters(monkeypatch): calls = _patch_request(monkeypatch, handler) 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 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): 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 len(calls) == 1 # no fetch-more assert "Activities:" in out diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 1765fac..3d2768b 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -51,6 +51,20 @@ def test_no_token_falls_back_to_env_config(monkeypatch): 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): monkeypatch.setattr(credentials, "get_access_token", lambda: None) monkeypatch.setattr( diff --git a/tests/test_custom_items.py b/tests/test_custom_items.py index 2222977..48a07a4 100644 --- a/tests/test_custom_items.py +++ b/tests/test_custom_items.py @@ -38,19 +38,19 @@ def _run(coro): # --------------------------------------------------------------------------- # def test_get_custom_items_lists(monkeypatch): _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 "ID: 1" in out and "Name: Zones" in out and "Type: ZONES" in out def test_get_custom_items_error(monkeypatch): _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): _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"}) out = _run( 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", ) ) @@ -78,7 +78,7 @@ def test_create_parses_json_string_content(monkeypatch): rec = _patch(monkeypatch, lambda _k: {"id": 1, "name": "X", "type": "ZONES"}) _run( 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 @@ -87,7 +87,7 @@ def test_create_parses_json_string_content(monkeypatch): def test_create_rejects_invalid_json_string(monkeypatch): rec = _patch(monkeypatch, lambda _k: {"id": 1}) 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 rec.calls == [] # bailed before any request @@ -95,7 +95,7 @@ def test_create_rejects_invalid_json_string(monkeypatch): def test_create_error_surfaced(monkeypatch): _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 @@ -104,7 +104,7 @@ def test_create_error_surfaced(monkeypatch): # --------------------------------------------------------------------------- # def test_update_sends_only_provided_fields_via_put(monkeypatch): 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] assert call["method"] == "PUT" 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): 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 rec.calls == [] @@ -123,7 +123,7 @@ def test_update_invalid_json_string(monkeypatch): # --------------------------------------------------------------------------- # def test_delete_uses_delete_method(monkeypatch): 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] assert call["method"] == "DELETE" assert call["url"] == "/athlete/i1/custom-item/3" @@ -132,4 +132,4 @@ def test_delete_uses_delete_method(monkeypatch): def test_delete_error(monkeypatch): _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)) diff --git a/tests/test_events.py b/tests/test_events.py index bb11428..f9715b2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -74,7 +74,7 @@ def test_handle_event_response_branches(result, action, needle): # --------------------------------------------------------------------------- # 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}]) - 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] assert call["url"] == "/athlete/i1/events" 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): _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 def test_get_events_empty(monkeypatch): _patch(monkeypatch, lambda _k: []) - out = _run(events.get_events(athlete_id="i1")) + out = _run(events.get_events()) assert "No events found" in out def test_get_event_by_id_invalid_format(monkeypatch): _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 def test_get_event_by_id_error(monkeypatch): _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 @@ -112,7 +112,7 @@ def test_add_event_posts_when_no_event_id(monkeypatch): rec = _patch(monkeypatch, lambda _k: {"id": "e99"}) out = _run( events.add_or_update_event( - workout_type="Ride", name="Threshold", athlete_id="i1", + workout_type="Ride", name="Threshold", start_date="2026-07-10", moving_time=3600, distance=40000, workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]), ) @@ -128,7 +128,7 @@ def test_update_event_puts_when_event_id(monkeypatch): rec = _patch(monkeypatch, lambda _k: {"id": "e5"}) _run( events.add_or_update_event( - workout_type="Ride", name="Threshold", athlete_id="i1", + workout_type="Ride", name="Threshold", 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): _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:") def test_add_note_uses_note_category_and_color(monkeypatch): 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"] assert data["category"] == "NOTE" 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): _patch(monkeypatch, lambda _k: {}) - out = _run(events.delete_event("", athlete_id="i1")) + out = _run(events.delete_event("")) assert "No event ID provided" in out def test_delete_event_uses_delete_method(monkeypatch): rec = _patch(monkeypatch, lambda _k: {"deleted": True}) - _run(events.delete_event("e7", athlete_id="i1")) + _run(events.delete_event("e7")) call = rec.calls[0] assert call["method"] == "DELETE" 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 _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 "Failed to delete 1 events: [2]" in out def test_delete_by_range_fetch_error(monkeypatch): _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 diff --git a/tests/test_server.py b/tests/test_server.py index 8cfb6e9..50ffb9c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -74,7 +74,7 @@ def test_get_activities(monkeypatch): monkeypatch.setattr( "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 "Activities:" in result @@ -122,7 +122,7 @@ def test_get_events(monkeypatch): # 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.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 "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 monkeypatch.setattr("intervals_mcp_server.api.client.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 "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 monkeypatch.setattr("intervals_mcp_server.api.client.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 "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.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 "2026-04-08" 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.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 "2024-01-01" in result assert "Fitness (CTL): 75" in result @@ -321,7 +321,7 @@ def test_add_or_update_event(monkeypatch): ) result = asyncio.run( 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 @@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch): result = asyncio.run( get_athlete_power_curves( activity_type="Ride", - athlete_id="i1", + ) ) assert "Power Curves (Ride):" in result @@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch): get_athlete_power_curves( activity_type="Ride", durations=[5, 60], - athlete_id="i1", + ) ) assert "5s:" in result @@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch): get_athlete_power_curves( activity_type="Ride", include_normalised=False, - athlete_id="i1", + ) ) assert "W/kg" not in result @@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch): get_athlete_power_curves( activity_type="Ride", start_date="2026-01-01", - athlete_id="i1", + ) ) assert "Error" in result @@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch): activity_type="Ride", this_season=False, last_season=False, - athlete_id="i1", + ) ) assert "Error" in result @@ -590,7 +590,7 @@ def test_get_custom_items(monkeypatch): monkeypatch.setattr( "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 "HR Zones" in result assert "ZONES" in result @@ -617,7 +617,7 @@ def test_get_custom_item_by_id(monkeypatch): monkeypatch.setattr( "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 "HR 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 ) 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 "New Chart" in result @@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch): create_custom_item( name="Activity Field", item_type="ACTIVITY_FIELD", - athlete_id="1", + 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 ) 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 "Updated Chart" in result @@ -724,7 +724,7 @@ def test_delete_custom_item(monkeypatch): monkeypatch.setattr( "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 @@ -744,7 +744,7 @@ def test_create_custom_item_with_invalid_json_content(monkeypatch): create_custom_item( name="Bad Item", item_type="FITNESS_CHART", - athlete_id="1", + 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 ) - result = asyncio.run(get_gear_list(athlete_id="i1")) + result = asyncio.run(get_gear_list()) assert "Gear catalog for athlete i1:" 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 ) - result = asyncio.run(get_gear_list(athlete_id="i1")) + result = asyncio.run(get_gear_list()) 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. - asyncio.run(get_gear_list(athlete_id="i1")) + asyncio.run(get_gear_list()) assert call_count["n"] == 1 # 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 # 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 @@ -944,7 +944,7 @@ def test_get_activities_resolves_gear_name(monkeypatch): "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 2" in result assert "Name: Litening Air" in result diff --git a/tests/test_tool_auth.py b/tests/test_tool_auth.py new file mode 100644 index 0000000..37a8e21 --- /dev/null +++ b/tests/test_tool_auth.py @@ -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