feat(multi-tenant): resolve per-caller credentials in every tool
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:
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user