Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s

- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching
  current Claude clients; the old 2025-06-18 server never got a tools/list on
  the connector surface).
- Bake transport config into code: stateless_http + json_response for HTTP
  (single JSON body instead of a 34KB SSE stream, which the connector pipeline
  handles far more reliably).
- Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth,
  configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the
  runtime FastMCP.__init__ monkeypatch from the k8s deployment command.
- Accept token audience with/without trailing slash (RFC 8707 clients use the
  slash-normalised resource metadata value).
- Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to
  build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:07:46 -04:00
commit 935abf86d4
46 changed files with 8485 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
API client module for Intervals.icu MCP Server.
This module contains the HTTP client and API request handling logic.
"""
+242
View File
@@ -0,0 +1,242 @@
"""
API client for Intervals.icu MCP Server.
This module handles all HTTP communication with the Intervals.icu API,
including request management, error handling, and client lifecycle.
"""
from json import JSONDecodeError
import json
import logging
import sys
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Any
import httpx # pylint: disable=import-error
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.config import get_config
logger = logging.getLogger("intervals_icu_mcp_server")
# Create a single AsyncClient instance for all requests (lazily initialized)
# This can be monkeypatched via server.httpx_client for testing
httpx_client: httpx.AsyncClient | None = None # pylint: disable=invalid-name
async def _get_httpx_client() -> httpx.AsyncClient:
"""
Lazily create or reuse the shared httpx AsyncClient.
The client may be closed by downstream transports between tool invocations,
so we recreate it when necessary.
This function checks server.httpx_client first (if available) to support
test monkeypatching via server.httpx_client.
"""
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
# Check for monkeypatched client in server module first (for test compatibility)
# This allows tests to monkeypatch server.httpx_client and have it work
try:
server_module = sys.modules.get("intervals_mcp_server.server")
if server_module and hasattr(server_module, "httpx_client"):
server_client = server_module.httpx_client
if server_client is not None and not server_client.is_closed:
return server_client
except (AttributeError, ImportError):
pass
# Use this module's httpx_client
if httpx_client is None or httpx_client.is_closed:
httpx_client = httpx.AsyncClient()
return httpx_client
@asynccontextmanager
async def setup_api_client(_app: FastMCP):
"""
Context manager to ensure the shared httpx client is closed when the server stops.
Args:
_app (FastMCP): The MCP server application instance.
"""
try:
yield
finally:
# Close the module-level httpx_client
if httpx_client and not httpx_client.is_closed:
await httpx_client.aclose()
# Also close server.httpx_client if it exists (for test compatibility)
# This ensures monkeypatched clients in tests are properly closed
try:
server_module = sys.modules.get("intervals_mcp_server.server")
if server_module and hasattr(server_module, "httpx_client"):
server_client = getattr(server_module, "httpx_client", None)
if server_client is not None and not server_client.is_closed:
await server_client.aclose()
except (AttributeError, ImportError):
pass
def _get_error_message(error_code: int, error_text: str) -> str:
"""Return a user-friendly error message for a given HTTP status code."""
error_messages = {
HTTPStatus.UNAUTHORIZED: f"{HTTPStatus.UNAUTHORIZED.value} {HTTPStatus.UNAUTHORIZED.phrase}: Please check your API key.",
HTTPStatus.FORBIDDEN: f"{HTTPStatus.FORBIDDEN.value} {HTTPStatus.FORBIDDEN.phrase}: You may not have permission to access this resource.",
HTTPStatus.NOT_FOUND: f"{HTTPStatus.NOT_FOUND.value} {HTTPStatus.NOT_FOUND.phrase}: The requested endpoint or ID doesn't exist.",
HTTPStatus.UNPROCESSABLE_ENTITY: f"{HTTPStatus.UNPROCESSABLE_ENTITY.value} {HTTPStatus.UNPROCESSABLE_ENTITY.phrase}: The server couldn't process the request (invalid parameters or unsupported operation).",
HTTPStatus.TOO_MANY_REQUESTS: f"{HTTPStatus.TOO_MANY_REQUESTS.value} {HTTPStatus.TOO_MANY_REQUESTS.phrase}: Too many requests in a short time period.",
HTTPStatus.INTERNAL_SERVER_ERROR: f"{HTTPStatus.INTERNAL_SERVER_ERROR.value} {HTTPStatus.INTERNAL_SERVER_ERROR.phrase}: The Intervals.icu server encountered an internal error.",
HTTPStatus.SERVICE_UNAVAILABLE: f"{HTTPStatus.SERVICE_UNAVAILABLE.value} {HTTPStatus.SERVICE_UNAVAILABLE.phrase}: The Intervals.icu server might be down or undergoing maintenance.",
}
try:
status = HTTPStatus(error_code)
return error_messages.get(status, error_text)
except ValueError:
return error_text
def _prepare_request_config(
url: str,
api_key: str | None,
method: str,
) -> tuple[str, httpx.BasicAuth, dict[str, str], str | None]:
"""Prepare request configuration including headers, auth, and URL.
Returns:
Tuple of (full_url, auth, headers, error_message).
error_message is None if configuration is valid.
"""
config = get_config()
headers = {"User-Agent": config.user_agent, "Accept": "application/json"}
if method in ["POST", "PUT"]:
headers["Content-Type"] = "application/json"
# Use provided api_key or fall back to global API_KEY
key_to_use = api_key if api_key is not None else config.api_key
if not key_to_use:
logger.error("No API key provided for request to: %s", url)
return (
"",
httpx.BasicAuth("", ""),
{},
"API key is required. Set API_KEY env var or pass api_key",
)
auth = httpx.BasicAuth("API_KEY", key_to_use)
full_url = f"{config.intervals_api_base_url}{url}"
return full_url, auth, headers, None
def _parse_response(
response: httpx.Response, full_url: str
) -> dict[str, Any] | list[dict[str, Any]]:
"""Parse HTTP response and return JSON data or error dict.
Returns:
Parsed JSON response or error dict.
"""
try:
response_data = response.json() if response.content else {}
except JSONDecodeError:
logger.error("Invalid JSON in response from: %s", full_url)
return {"error": True, "message": "Invalid JSON in response"}
response.raise_for_status()
return response_data
async def make_intervals_request(
url: str,
api_key: str | None = None,
params: dict[str, Any] | None = None,
method: str = "GET",
data: dict[str, Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
"""
Make a request to the Intervals.icu API with proper error handling.
Args:
url (str): The API endpoint path (e.g., '/athlete/{id}/activities').
api_key (str | None): Optional API key to use for authentication. Defaults to the global API_KEY.
params (dict[str, Any] | None): Optional query parameters for the request.
method (str): HTTP method to use (GET, POST, etc.). Defaults to GET.
data (dict[str, Any] | None): Optional data to send in the request body.
Returns:
dict[str, Any] | list[dict[str, Any]]: The parsed JSON response from the API, or an error dict.
"""
# Prepare request configuration
full_url, auth, headers, error_msg = _prepare_request_config(url, api_key, method)
if error_msg:
return {"error": True, "message": error_msg}
async def _send_request(client: httpx.AsyncClient) -> httpx.Response:
if method in {"POST", "PUT"} and data is not None:
body = json.dumps(data)
logger.debug("Request %s %s body: %s", method, full_url, body)
return await client.request(
method=method,
url=full_url,
headers=headers,
params=params,
auth=auth,
timeout=30.0,
content=body,
)
return await client.request(
method=method,
url=full_url,
headers=headers,
params=params,
auth=auth,
timeout=30.0,
)
try:
client = await _get_httpx_client()
try:
response = await _send_request(client)
except RuntimeError as runtime_error:
# httpx closes the client when the underlying connection is severed;
# recreate the shared client lazily and retry once.
if "client has been closed" not in str(runtime_error).lower():
raise
logger.warning("HTTPX client was closed; creating a new instance for retries.")
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
httpx_client = None
client = await _get_httpx_client()
response = await _send_request(client)
return _parse_response(response, full_url)
except httpx.HTTPStatusError as e:
return _handle_http_status_error(e)
except httpx.RequestError as e:
logger.error("Request error: %s", str(e))
return {"error": True, "message": f"Request error: {str(e)}"}
except httpx.HTTPError as e:
logger.error("HTTP client error: %s", str(e))
return {"error": True, "message": f"HTTP client error: {str(e)}"}
def _handle_http_status_error(e: httpx.HTTPStatusError) -> dict[str, Any]:
"""Handle HTTP status errors and return formatted error dict.
Args:
e: The HTTPStatusError exception.
Returns:
Error dictionary with status code and message.
"""
error_code = e.response.status_code
error_text = e.response.text
logger.error("HTTP error: %s - %s", error_code, error_text)
return {
"error": True,
"status_code": error_code,
"message": _get_error_message(error_code, error_text),
}
+101
View File
@@ -0,0 +1,101 @@
"""
Native OAuth token verification for the Intervals.icu MCP Server.
This replaces the previous runtime monkeypatch: authentication is configured
here, in code, and enabled automatically when the OAuth environment variables
(``MCP_ISSUER`` / ``MCP_RESOURCE`` / ``MCP_JWKS_URI``) are present — i.e. for
the HTTP transport running behind an OAuth authorization server (Authentik).
When those variables are absent (e.g. stdio / local development / tests) auth
is disabled and the server runs unauthenticated.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger("intervals_icu_mcp_server")
class AuthentikTokenVerifier:
"""Verify RS256 Bearer JWTs against a JWKS endpoint (RFC 9068 style)."""
def __init__(self, jwks_uri: str, issuer: str, audience: list[str]):
import jwt # PyJWT
self._jwks = jwt.PyJWKClient(jwks_uri)
self._issuer = issuer
self._audience = audience
async def verify_token(self, token: str):
"""Return an AccessToken if the JWT is valid, else None (unauthenticated)."""
import jwt
from mcp.server.auth.provider import AccessToken
try:
key = self._jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
issuer=self._issuer,
audience=self._audience,
options={"require": ["exp", "iat", "iss", "aud"]},
)
except Exception as exc: # noqa: BLE001 - any failure means unauthenticated
logger.debug("Token verification failed: %s", exc)
return None
aud = claims.get("aud")
resource = aud[0] if isinstance(aud, list) else aud
return AccessToken(
token=token,
client_id=claims.get("azp") or resource,
scopes=(claims.get("scope") or "").split(),
expires_at=claims.get("exp"),
resource=resource,
subject=claims.get("sub"),
claims=claims,
)
def _audience_variants(resource: str, client_id: str | None) -> list[str]:
"""Accepted token audiences.
RFC 8707 clients use the ``resource`` value advertised in the protected
resource metadata, which pydantic's ``AnyHttpUrl`` normalises *with* a
trailing slash. The raw env var is typically supplied *without* one, so we
accept both forms (plus the OAuth client_id) to avoid audience mismatches.
"""
base = resource.rstrip("/")
values = [base, base + "/"]
if client_id:
values.append(client_id)
# de-duplicate while preserving order
seen: dict[str, None] = {}
for value in values:
seen.setdefault(value, None)
return list(seen.keys())
def build_auth():
"""Return ``(AuthSettings, TokenVerifier)`` when OAuth is configured, else ``(None, None)``."""
issuer = os.getenv("MCP_ISSUER")
resource = os.getenv("MCP_RESOURCE")
jwks_uri = os.getenv("MCP_JWKS_URI")
client_id = os.getenv("MCP_CLIENT_ID")
if not (issuer and resource and jwks_uri):
return None, None
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl
settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(resource),
)
verifier = AuthentikTokenVerifier(jwks_uri, issuer, _audience_variants(resource, client_id))
logger.info("Native OAuth enabled (issuer=%s, resource=%s)", issuer, resource)
return settings, verifier
+72
View File
@@ -0,0 +1,72 @@
"""
Configuration management for Intervals.icu MCP Server.
This module handles loading and validation of configuration from environment variables.
"""
import os
from dataclasses import dataclass
from intervals_mcp_server.utils.validation import validate_athlete_id
# Try to load environment variables from .env file if it exists
try:
from dotenv import load_dotenv
_ = load_dotenv()
except ImportError:
# python-dotenv not installed, proceed without it
pass
@dataclass
class Config:
"""Configuration settings for the Intervals.icu MCP Server."""
api_key: str
athlete_id: str
intervals_api_base_url: str
user_agent: str
_config_instance: Config | None = None # pylint: disable=invalid-name
def load_config() -> Config:
"""
Load configuration from environment variables.
Returns:
Config: Configuration instance with loaded values.
Raises:
ValueError: If athlete_id is invalid (when non-empty).
"""
api_key = os.getenv("API_KEY", "")
athlete_id = os.getenv("ATHLETE_ID", "")
intervals_api_base_url = os.getenv("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1")
user_agent = "intervalsicu-mcp-server/1.0"
# Validate athlete_id if provided (empty string is allowed)
if athlete_id:
validate_athlete_id(athlete_id)
return Config(
api_key=api_key,
athlete_id=athlete_id,
intervals_api_base_url=intervals_api_base_url,
user_agent=user_agent,
)
def get_config() -> Config:
"""
Get the configuration instance (singleton pattern).
Returns:
Config: The configuration instance.
"""
global _config_instance # pylint: disable=global-statement # noqa: PLW0603 - singleton pattern
if _config_instance is None:
_config_instance = load_config()
return _config_instance
+43
View File
@@ -0,0 +1,43 @@
"""
Shared MCP instance module.
Provides a shared FastMCP instance imported by both the server module and the
tool modules (avoiding cyclic imports). Transport and authentication are
configured here from the environment — there is no runtime monkeypatching:
- HTTP transport (``MCP_TRANSPORT=http``/``streamable-http``) is served
statelessly with plain JSON responses (robust behind proxies / MCP
connectors, which handle a single JSON body far better than a large SSE
stream).
- Native OAuth (Authentik) is enabled when ``MCP_ISSUER`` / ``MCP_RESOURCE`` /
``MCP_JWKS_URI`` are set. See :mod:`intervals_mcp_server.auth`.
"""
from __future__ import annotations
import os
from typing import Any
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.api.client import setup_api_client
from intervals_mcp_server.auth import build_auth
_kwargs: dict[str, Any] = {"lifespan": setup_api_client}
# HTTP transport tuning: stateless + single JSON response body.
if os.getenv("MCP_TRANSPORT", "stdio").lower() in ("http", "streamable-http"):
_kwargs["stateless_http"] = True
_kwargs["json_response"] = True
if os.getenv("FASTMCP_HOST"):
_kwargs["host"] = os.environ["FASTMCP_HOST"]
if os.getenv("FASTMCP_PORT"):
_kwargs["port"] = int(os.environ["FASTMCP_PORT"])
# Native OAuth (Authentik) when configured via environment.
_auth_settings, _token_verifier = build_auth()
if _auth_settings is not None and _token_verifier is not None:
_kwargs["auth"] = _auth_settings
_kwargs["token_verifier"] = _token_verifier
mcp: FastMCP = FastMCP("intervals-icu", **_kwargs) # pylint: disable=invalid-name
View File
+132
View File
@@ -0,0 +1,132 @@
"""
Intervals.icu MCP Server
This module implements a Model Context Protocol (MCP) server for connecting
Claude with the Intervals.icu API. It provides tools for retrieving and managing
athlete data, including activities, events, workouts, and wellness metrics.
Main Features:
- Activity retrieval and detailed analysis
- Event management (races, workouts, calendar items)
- Wellness data tracking and visualization
- Error handling with user-friendly messages
- Configurable parameters with environment variable support
Usage:
This server is designed to be run as a standalone script and exposes several MCP tools
for use with Claude Desktop or other MCP-compatible clients. The server loads configuration
from environment variables (optionally via a .env file) and communicates with the Intervals.icu API.
To run the server:
$ python src/intervals_mcp_server/server.py
MCP tools provided:
- get_activities
- get_activity_details
- get_activity_intervals
- get_activity_streams
- get_activity_messages
- add_activity_message
- get_events
- get_event_by_id
- add_or_update_event
- delete_event
- delete_events_by_date_range
- get_wellness_data
- get_athlete_power_curves
- get_custom_items
- get_custom_item_by_id
- create_custom_item
- update_custom_item
- delete_custom_item
See the README for more details on configuration and usage.
"""
import logging
# Import API client and configuration
from intervals_mcp_server.api.client import (
httpx_client, # Re-export for backward compatibility with tests
make_intervals_request,
)
from intervals_mcp_server.config import get_config
from intervals_mcp_server.mcp_instance import mcp
# Import types and validation
from intervals_mcp_server.server_setup import setup_transport, start_server
from intervals_mcp_server.utils.validation import validate_athlete_id
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("intervals_icu_mcp_server")
# Get configuration instance
config = get_config()
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
# Import tool functions for re-export
from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402
add_activity_message,
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_messages,
get_activity_streams,
)
from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-position # noqa: E402
add_or_update_event,
delete_event,
delete_events_by_date_range,
get_event_by_id,
get_events,
)
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item,
delete_custom_item,
get_custom_item_by_id,
get_custom_items,
update_custom_item,
)
# Re-export make_intervals_request and httpx_client for backward compatibility
# pylint: disable=duplicate-code # This __all__ list is intentionally similar to tools/__init__.py
__all__ = [
"make_intervals_request",
"httpx_client", # Re-exported for test compatibility
"add_activity_message",
"get_activities",
"get_activity_details",
"get_activity_intervals",
"get_activity_messages",
"get_activity_streams",
"get_events",
"get_event_by_id",
"delete_event",
"delete_events_by_date_range",
"add_or_update_event",
"get_wellness_data",
"get_athlete_power_curves",
"get_custom_items",
"get_custom_item_by_id",
"create_custom_item",
"update_custom_item",
"delete_custom_item",
]
# Run the server
if __name__ == "__main__":
# Validate ATHLETE_ID when server starts (not at import time to allow tests)
validate_athlete_id(config.athlete_id)
# Setup transport and start server
selected_transport = setup_transport()
start_server(mcp, selected_transport)
+78
View File
@@ -0,0 +1,78 @@
"""
Server setup and initialization for Intervals.icu MCP Server.
This module handles transport configuration and server startup logic.
"""
import os
import logging
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.utils.types import TransportAliases
logger = logging.getLogger("intervals_icu_mcp_server")
def setup_transport() -> TransportAliases:
"""
Setup and validate the MCP transport configuration.
Reads MCP_TRANSPORT environment variable and validates it against
supported transport types.
Returns:
TransportAliases: The selected transport type.
Raises:
ValueError: If the transport type is not supported.
"""
transport_env = os.getenv("MCP_TRANSPORT", TransportAliases.STDIO.value).lower()
try:
transport_alias = TransportAliases(transport_env)
except ValueError as exc:
allowed = ", ".join(item.value for item in TransportAliases)
raise ValueError(f"Unsupported MCP_TRANSPORT value. Use one of: {allowed}.") from exc
# Map HTTP to STREAMABLE_HTTP
selected_transport = (
TransportAliases.STREAMABLE_HTTP
if transport_alias == TransportAliases.HTTP
else transport_alias
)
return selected_transport
def start_server(mcp_instance: FastMCP, transport: TransportAliases) -> None:
"""
Start the MCP server with the specified transport.
Args:
mcp_instance (FastMCP): The FastMCP server instance to start.
transport (TransportAliases): The transport type to use.
"""
host = mcp_instance.settings.host
port = mcp_instance.settings.port
if transport == TransportAliases.STDIO:
logger.info("Starting MCP server with stdio transport.")
mcp_instance.run()
elif transport == TransportAliases.SSE:
mount_path = os.getenv("MCP_SSE_MOUNT_PATH")
logger.info(
"Starting MCP server with SSE transport at http://%s:%s%s (messages: %s).",
host,
port,
mcp_instance.settings.sse_path,
mcp_instance.settings.message_path,
)
mcp_instance.run(transport="sse", mount_path=mount_path)
else: # STREAMABLE_HTTP
logger.info(
"Starting MCP server with Streamable HTTP transport at http://%s:%s%s.",
host,
port,
mcp_instance.settings.streamable_http_path,
)
mcp_instance.run(transport="streamable-http")
@@ -0,0 +1,73 @@
"""
MCP tools registry for Intervals.icu MCP Server.
This module registers all available MCP tools with the FastMCP server instance.
"""
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
# Import all tools for re-export
# Note: Tools register themselves via @mcp.tool() decorators when imported
from intervals_mcp_server.tools.activities import ( # noqa: F401
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_streams,
)
from intervals_mcp_server.tools.events import ( # noqa: F401
add_or_update_event,
delete_event,
delete_events_by_date_range,
get_event_by_id,
get_events,
)
from intervals_mcp_server.tools.custom_items import ( # noqa: F401
create_custom_item,
delete_custom_item,
get_custom_item_by_id,
get_custom_items,
update_custom_item,
)
from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves,
)
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401
def register_tools(mcp_instance: FastMCP) -> None:
"""
Register all MCP tools with the FastMCP server instance.
This function imports all tool modules, which causes their @mcp.tool()
decorators to register the tools. The tools need access to the mcp instance,
so they will be imported after the mcp instance is created.
Args:
mcp_instance (FastMCP): The FastMCP server instance to register tools with.
"""
# Tools are registered via decorators when modules are imported above
# The mcp_instance parameter is kept for future use if needed
_ = mcp_instance
__all__ = [
"register_tools",
"get_activities",
"get_activity_details",
"get_activity_intervals",
"get_activity_streams",
"get_events",
"get_event_by_id",
"delete_event",
"delete_events_by_date_range",
"add_or_update_event",
"get_custom_items",
"get_custom_item_by_id",
"create_custom_item",
"update_custom_item",
"delete_custom_item",
"get_athlete_power_curves",
"get_gear_list",
"get_wellness_data",
]
@@ -0,0 +1,394 @@
"""
Activity-related MCP tools for Intervals.icu.
This module contains tools for retrieving and managing athlete activities.
"""
from datetime import datetime, timedelta
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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
# 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."""
activities: list[dict[str, Any]] = []
if isinstance(result, list):
activities = [item for item in result if isinstance(item, dict)]
elif isinstance(result, dict):
# Result is a single activity or a container
for _key, value in result.items():
if isinstance(value, list):
activities = [item for item in value if isinstance(item, dict)]
break
# If no list was found but the dict has typical activity fields, treat it as a single activity
if not activities and any(key in result for key in ["name", "startTime", "distance"]):
activities = [result]
return activities
def _filter_named_activities(activities: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Filter out unnamed activities from the list."""
return [
activity
for activity in activities
if activity.get("name") and activity.get("name") != "Unnamed"
]
async def _fetch_more_activities(
athlete_id: str,
start_date: str,
api_key: str | None,
api_limit: int,
) -> list[dict[str, Any]]:
"""Fetch additional activities from an earlier date range."""
oldest_date = datetime.fromisoformat(start_date)
older_start_date = (oldest_date - timedelta(days=60)).strftime("%Y-%m-%d")
older_end_date = (oldest_date - timedelta(days=1)).strftime("%Y-%m-%d")
if older_start_date >= older_end_date:
return []
more_params = {
"oldest": older_start_date,
"newest": older_end_date,
"limit": api_limit,
}
more_result = await make_intervals_request(
url=f"/athlete/{athlete_id}/activities",
api_key=api_key,
params=more_params,
)
if isinstance(more_result, list):
return _filter_named_activities(more_result)
return []
def _format_activities_response(
activities: list[dict[str, Any]],
athlete_id: str,
include_unnamed: bool,
) -> str:
"""Format the activities response based on the results."""
if not activities:
if include_unnamed:
return (
f"No valid activities found for athlete {athlete_id} in the specified date range."
)
return f"No named activities found for athlete {athlete_id} in the specified date range. Try with include_unnamed=True to see all activities."
# Format the output
activities_summary = "Activities:\n\n"
for activity in activities:
if isinstance(activity, dict):
activities_summary += format_activity_summary(activity) + "\n"
else:
activities_summary += f"Invalid activity format: {activity}\n\n"
return activities_summary
@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,
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
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
start_date, end_date = resolve_date_params(start_date, end_date)
# Fetch more activities if we need to filter out unnamed ones
api_limit = limit * 3 if not include_unnamed else limit
# Call the Intervals.icu API
params = {"oldest": start_date, "newest": end_date, "limit": api_limit}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/activities", api_key=api_key, params=params
)
# Check for error
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activities: {error_message}"
if not result:
return f"No activities found for athlete {athlete_id_to_use} in the specified date range."
# Parse activities from result
activities = _parse_activities_from_result(result)
if not activities:
return f"No valid activities found for athlete {athlete_id_to_use} in the specified date range."
# Filter and fetch more if needed
if not include_unnamed:
activities = _filter_named_activities(activities)
# If we don't have enough named activities, try to fetch more
if len(activities) < limit:
more_activities = await _fetch_more_activities(
athlete_id_to_use, start_date, api_key, api_limit
)
activities.extend(more_activities)
# Limit to requested count
activities = activities[:limit]
# Resolve gear names (in-place injection of `_resolved_gear_name`)
await resolve_gear_for_activities(
activities, athlete_id=athlete_id_to_use, api_key=api_key
)
return _format_activities_response(activities, athlete_id_to_use, include_unnamed)
@mcp.tool()
async def get_activity_details(activity_id: str, api_key: str | None = None) -> 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)
"""
# Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity details: {error_message}"
# Format the response
if not result:
return f"No details found for activity {activity_id}."
# If result is a list, use the first item if available
activity_data = result[0] if isinstance(result, list) and result else result
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)
# Return a more detailed view of the activity
detailed_view = format_activity_summary(activity_data)
# Add additional details if available
if "zones" in activity_data:
zones = activity_data["zones"]
detailed_view += "\nPower Zones:\n"
for zone in zones.get("power", []):
detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n"
detailed_view += "\nHeart Rate Zones:\n"
for zone in zones.get("hr", []):
detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n"
return detailed_view
@mcp.tool()
async def get_activity_intervals(activity_id: str, api_key: str | None = None) -> 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,
cadence, speed, and environmental data. It also includes grouped intervals if applicable.
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)
"""
# Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching intervals: {error_message}"
# Format the response
if not result:
return f"No interval data found for activity {activity_id}."
# If the result is empty or doesn't contain expected fields
if not isinstance(result, dict) or not any(
key in result for key in ["icu_intervals", "icu_groups"]
):
return f"No interval data or unrecognized format for activity {activity_id}."
# Format the intervals data
return format_intervals(result)
@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
This endpoint returns time-series data for an activity, including metrics like power, heart rate,
cadence, altitude, distance, temperature, and velocity data.
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
"""
# Build query parameters
params = {}
if stream_types:
params["types"] = stream_types
else:
# Default to common stream types if none specified
params["types"] = "time,watts,heartrate,cadence,altitude,distance,velocity_smooth"
# Call the Intervals.icu API
result = await make_intervals_request(
url=f"/activity/{activity_id}/streams",
api_key=api_key,
params=params,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity streams: {error_message}"
# Format the response
if not result:
return f"No stream data found for activity {activity_id}."
# Ensure result is a list
streams = result if isinstance(result, list) else []
if not streams:
return f"No stream data found for activity {activity_id}."
# Format the streams data
streams_summary = f"Activity Streams for {activity_id}:\n\n"
for stream in streams:
if not isinstance(stream, dict):
continue
stream_type = stream.get("type", "unknown")
stream_name = stream.get("name", stream_type)
data = stream.get("data", [])
value_type = stream.get("valueType", "")
streams_summary += f"Stream: {stream_name} ({stream_type})\n"
streams_summary += f" Value Type: {value_type}\n"
streams_summary += f" Data Points: {len(data)}\n"
# Show first few and last few data points for preview
if data:
if len(data) <= 10:
streams_summary += f" Values: {data}\n"
else:
preview_start = data[:5]
preview_end = data[-5:]
streams_summary += f" First 5 values: {preview_start}\n"
streams_summary += f" Last 5 values: {preview_end}\n"
streams_summary += "\n"
return streams_summary
@mcp.tool()
async def get_activity_messages(activity_id: str, api_key: str | None = None) -> 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)
"""
result = await make_intervals_request(
url=f"/activity/{activity_id}/messages",
api_key=api_key,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity messages: {error_message}"
if not result:
return f"No messages found for activity {activity_id}."
messages = result if isinstance(result, list) else []
if not messages:
return f"No messages found for activity {activity_id}."
output = f"Messages for activity {activity_id}:\n\n"
for msg in messages:
if isinstance(msg, dict):
output += format_activity_message(msg) + "\n\n"
return output
@mcp.tool()
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)
"""
result = await make_intervals_request(
url=f"/activity/{activity_id}/messages",
api_key=api_key,
method="POST",
data={"content": content},
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error adding message to activity: {error_message}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when adding message."
msg_id = result.get("id")
if msg_id is not None:
return f"Successfully added message (ID: {msg_id}) to activity {activity_id}."
return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually."
@@ -0,0 +1,232 @@
"""
Custom items MCP tools for Intervals.icu.
This module contains tools for managing athlete custom items (charts, fields, zones, etc.).
"""
import json
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom items: {result.get('message')}"
if not result:
return f"No custom items found for athlete {athlete_id_to_use}."
output = "Custom Items:\n\n"
for item in result:
if isinstance(item, dict):
output += f"- ID: {item.get('id')}\n"
output += f" Name: {item.get('name', 'N/A')}\n"
output += f" Type: {item.get('type', 'N/A')}\n"
if item.get("description"):
output += f" Description: {item['description']}\n"
output += "\n"
return output
@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
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return f"No custom item found with ID {item_id}."
return format_custom_item_details(result)
@mcp.tool()
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,
) -> str:
"""Create a new custom item for an athlete on Intervals.icu
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
data: dict[str, Any] = {"name": name, "type": item_type}
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item",
api_key=api_key,
data=data,
method="POST",
)
if isinstance(result, dict) and "error" in result:
return f"Error creating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when creating custom item."
return f"Successfully created custom item:\n\n{format_custom_item_details(result)}"
@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,
content: dict[str, Any] | None = None,
visibility: str | None = None,
) -> str:
"""Update an existing custom item for an athlete on Intervals.icu
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)
content: New 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: 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
data: dict[str, Any] = {}
if name is not None:
data["name"] = name
if item_type is not None:
data["type"] = item_type
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
data=data,
method="PUT",
)
if isinstance(result, dict) and "error" in result:
return f"Error updating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when updating custom item."
return f"Successfully updated custom item:\n\n{format_custom_item_details(result)}"
@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
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting custom item: {result.get('message')}"
return f"Successfully deleted custom item {item_id}."
+433
View File
@@ -0,0 +1,433 @@
"""
Event-related MCP tools for Intervals.icu.
This module contains tools for retrieving, creating, updating, and deleting athlete events.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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
# 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,
workout_type: str,
start_date: str,
workout_doc: WorkoutDoc | None,
moving_time: int | None,
distance: int | None,
) -> dict[str, Any]:
"""Prepare event data dictionary for API request.
Many arguments are required to match the Intervals.icu API event structure.
"""
resolved_workout_type = resolve_activity_type(name, workout_type)
return {
"start_date_local": start_date + "T00:00:00",
"category": "WORKOUT",
"name": name,
"description": str(workout_doc) if workout_doc else None,
"type": resolved_workout_type,
"moving_time": moving_time,
"distance": distance,
}
def _handle_event_response(
result: dict[str, Any] | list[dict[str, Any]] | None,
action: str,
athlete_id: str,
start_date: str,
) -> str:
"""Handle API response and format appropriate message."""
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error {action} event: {error_message}"
if not result:
return f"No events {action} for athlete {athlete_id}."
if isinstance(result, dict):
return f"Successfully {action} event id: {result.get('id')}"
return f"Event {action} successfully at {start_date}"
async def _delete_events_list(
athlete_id: str, api_key: str | None, events: list[dict[str, Any]]
) -> list[int | str | None]:
"""Delete a list of events and return IDs of failed deletions.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
events: List of event dictionaries to delete.
Returns:
List of event IDs that failed to delete.
"""
failed_events: list[int | str | None] = []
for event in events:
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events/{event.get('id')}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
failed_events.append(event.get("id"))
return failed_events
@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
# Parse date parameters (events use different defaults)
if not start_date:
start_date = get_default_end_date()
if not end_date:
end_date = get_default_future_end_date()
# Call the Intervals.icu API
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching events: {error_message}"
# Format the response
if not result:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
# Ensure result is a list
events = result if isinstance(result, list) else []
if not events:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
events_summary = "Events:\n\n"
for event in events:
if not isinstance(event, dict):
continue
events_summary += format_event_summary(event) + "\n\n"
return events_summary
@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
# Call the Intervals.icu API
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/event/{event_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching event details: {error_message}"
# Format the response
if not result:
return f"No details found for event {event_id}."
if not isinstance(result, dict):
return f"Invalid event format for event {event_id}."
return format_event_details(result)
@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
if not event_id:
return "Error: No event ID provided."
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events/{event_id}", api_key=api_key, method="DELETE"
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting event: {result.get('message')}"
return json.dumps(result, indent=2)
async def _fetch_events_for_deletion(
athlete_id: str, api_key: str | None, start_date: str, end_date: str
) -> tuple[list[dict[str, Any]], str | None]:
"""Fetch events for deletion and return them with any error message.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
Tuple of (events_list, error_message). error_message is None if successful.
"""
params = {"oldest": validate_date(start_date), "newest": validate_date(end_date)}
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return [], f"Error deleting events: {result.get('message')}"
events = result if isinstance(result, list) else []
return events, None
@mcp.tool()
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
events, error_msg = await _fetch_events_for_deletion(
athlete_id_to_use, api_key, start_date, end_date
)
if error_msg:
return error_msg
failed_events = await _delete_events_list(athlete_id_to_use, api_key, events)
deleted_count = len(events) - len(failed_events)
return f"Deleted {deleted_count} events. Failed to delete {len(failed_events)} events: {failed_events}"
@mcp.tool()
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,
moving_time: int | None = None,
distance: int | None = None,
) -> str:
"""Post event for an athlete to Intervals.icu this follows the event api from intervals.icu
If event_id is provided, the event will be updated instead of created.
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
workout_doc: steps as a list of Step objects (optional, but necessary to define workout steps)
workout_type: Workout type (e.g. Ride, Run, Swim, Walk, Row)
moving_time: Total expected moving time of the workout in seconds (optional)
distance: Total expected distance of the workout in meters (optional)
Example:
"workout_doc": {
"description": "High-intensity workout for increasing VO2 max",
"steps": [
{"power": {"value": 80, "units": "%ftp"}, "duration": 900, "warmup": true},
{"reps": 2, "text": "High-intensity intervals", "steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]},
{"power": {"value": 80, "units": "%ftp"}, "duration": 600, "cooldown": true},
{"text": ""}
]
}
Step properties:
distance: Distance of step in meters
{"distance": 5000}
duration: Duration of step in seconds
{"duration": 1800}
power/hr/pace/cadence: Define step intensity
Percentage of FTP: {"power": {"value": 80, "units": "%ftp"}}
Absolute power: {"power": {"value": 200, "units": "w"}}
Heart rate: {"hr": {"value": 75, "units": "%hr"}}
Heart rate (LTHR): {"hr": {"value": 85, "units": "%lthr"}}
Cadence: {"cadence": {"value": 90, "units": "cadence"}}
Pace by ftp: {"pace": {"value": 80, "units": "%pace"}}
Pace by zone: {"pace": {"value": 2, "units": "pace_zone"}}
Zone by power: {"power": {"value": 2, "units": "power_zone"}}
Zone by heart rate: {"hr": {"value": 2, "units": "hr_zone"}}
Ranges: Specify ranges for power, heart rate, or cadence:
{"power": {"start": 80, "end": 90, "units": "%ftp"}}
Ramps: Instead of a range, indicate a gradual change in intensity (useful for ERG workouts):
{"ramp": true, "power": {"start": 80, "end": 90, "units": "%ftp"}}
Repeats: include the reps property and add nested steps
{"reps": 3,
"steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]}
Free Ride: Include freeride to indicate a segment without ERG control, optionally with a suggested power range:
{"freeride": true, "power": {"value": 80, "units": "%ftp"}}
Comments and Labels: Add descriptive text to label steps:
{"text": "Warmup"}
How to use steps:
- Set distance or duration as appropriate for step
- 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
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = _prepare_event_data(
name, workout_type, validated_date, workout_doc, moving_time, distance
)
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
@mcp.tool()
async def add_or_update_note(
name: str,
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.
Args:
name: Title of the note
description: Plain text content of the note
start_date: Date in YYYY-MM-DD format (optional, defaults to today)
color: Color of the note (e.g. green, orange, red, blue)
athlete_id: The Intervals.icu athlete ID (optional)
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
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = {
"category": "NOTE",
"name": name,
"description": description,
"start_date_local": validated_date + "T00:00:00",
"color": color
}
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
async def _create_or_update_event_request(
athlete_id: str,
api_key: str | None,
event_data: dict[str, Any],
start_date: str,
event_id: str | None,
) -> str:
"""Create or update an event via API request.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
event_data: Prepared event data dictionary.
start_date: Start date string for response formatting.
event_id: Optional event ID for updates.
Returns:
Formatted response string.
"""
url = f"/athlete/{athlete_id}/events"
if event_id:
url += f"/{event_id}"
result = await make_intervals_request(
url=url,
api_key=api_key,
data=event_data,
method="PUT" if event_id else "POST",
)
action = "updated" if event_id else "created"
return _handle_event_response(result, action, athlete_id, start_date)
+198
View File
@@ -0,0 +1,198 @@
"""
Gear-related MCP tools for Intervals.icu.
This module provides:
- A module-level cache of the athlete's raw gear catalog (bikes, shoes, etc.)
to avoid hitting the /athlete/{id}/gear endpoint on every activity lookup.
- A helper to inject the human-readable gear name into an activity dict (under
`_resolved_gear_name`), which the formatter then displays in the `Gear:` block.
- A user-facing MCP tool `get_gear_list` so the assistant can discover or
refresh the gear catalog on demand.
Intervals.icu's activity payload includes only the gear ID (e.g. `b16177481`)
but not the gear name. The gear name lives in a separate endpoint
`/athlete/{athlete_id}/gear` that returns the full catalog. To avoid an extra
round-trip per activity, we cache the raw gear catalog per athlete for the
lifetime of the MCP server process and derive the `{id: name}` lookup from it.
Call `get_gear_list(refresh=True)` to bust the cache.
"""
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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()
# Module-level cache of the raw gear catalog per athlete. Single source of
# truth: the id->name map and the rich listing are both derived from this.
_GEAR_RAW_CACHE: dict[str, list[dict[str, Any]]] = {}
def _extract_gear_id(activity: dict[str, Any]) -> str | None:
"""Pull the gear ID out of an activity dict, handling the two known shapes."""
gear_raw = activity.get("gear")
if isinstance(gear_raw, dict):
gear_id = gear_raw.get("id")
if gear_id:
return str(gear_id)
gear_id = activity.get("gear_id")
if gear_id:
return str(gear_id)
return None
def _items_from_response(result: Any) -> list[dict[str, Any]]:
"""Normalize the /athlete/{id}/gear response into a list of gear dicts."""
if isinstance(result, list):
return [item for item in result if isinstance(item, dict)]
if isinstance(result, dict):
# Some endpoints wrap the list in a container; pull any list value.
for value in result.values():
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _derive_gear_map(items: list[dict[str, Any]]) -> dict[str, str]:
"""Convert a raw gear list into a {gear_id: gear_name} lookup."""
gear_map: dict[str, str] = {}
for item in items:
gid = item.get("id")
name = item.get("name") or item.get("display_name")
if gid and name:
gear_map[str(gid)] = str(name)
return gear_map
async def get_gear_raw(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> list[dict[str, Any]]:
"""Return (and cache) the raw gear list for an athlete.
Single source of truth that backs both the id->name map and the rich
listing produced by `get_gear_list`. One API call per athlete per process
lifetime unless `refresh=True`.
Args:
athlete_id: Athlete to look up. Defaults to ATHLETE_ID env var via config.
api_key: Override the configured API key.
refresh: If True, ignore the cache and re-fetch from the API.
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg or not athlete_id_to_use:
return []
if not refresh and athlete_id_to_use in _GEAR_RAW_CACHE:
return _GEAR_RAW_CACHE[athlete_id_to_use]
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/gear", api_key=api_key
)
items = _items_from_response(result)
_GEAR_RAW_CACHE[athlete_id_to_use] = items
return items
async def get_gear_map(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> dict[str, str]:
"""Return the {gear_id: gear_name} lookup for an athlete (derived from cache)."""
items = await get_gear_raw(athlete_id=athlete_id, api_key=api_key, refresh=refresh)
return _derive_gear_map(items)
async def resolve_gear_for_activity(
activity: dict[str, Any],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into an activity dict if gear info is present.
Mutates the activity dict in place. Safe to call when gear is absent (no-op).
Uses the cached gear map; the first call per athlete triggers a fetch.
"""
gear_id = _extract_gear_id(activity)
if not gear_id:
return
gear_map = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
name = gear_map.get(gear_id)
if name:
activity["_resolved_gear_name"] = name
async def resolve_gear_for_activities(
activities: list[dict[str, Any]],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into each activity in a list. In-place."""
if not activities:
return
# Pre-warm the cache once, then iterate.
_ = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
for activity in activities:
if isinstance(activity, dict):
await resolve_gear_for_activity(
activity, athlete_id=athlete_id, api_key=api_key
)
@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.
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)."
# Single fetch path: get_gear_raw consults the cache and only hits the API
# on a cold cache or when refresh=True.
items = await get_gear_raw(
athlete_id=athlete_id_to_use, api_key=api_key, refresh=refresh
)
if not items:
return f"No gear found for athlete {athlete_id_to_use}."
output = f"Gear catalog for athlete {athlete_id_to_use}:\n\n"
output += f"{'ID':<14} {'Type':<8} {'Name':<32} {'Default':<8} {'Acts':<6} {'Dist (km)':<10} {'Retired':<8}\n"
output += f"{'-' * 14} {'-' * 8} {'-' * 32} {'-' * 8} {'-' * 6} {'-' * 10} {'-' * 8}\n"
for it in items:
gid = str(it.get("id", "?"))
gtype = str(it.get("component_type", it.get("type", "?")))
name = str(it.get("name", "?"))[:32]
default_for = it.get("default_for_type") or it.get("default_for") or ""
acts = str(it.get("activities", it.get("activity_count", "?")))
dist_m = it.get("distance", 0) or 0
dist_km = f"{dist_m / 1000:.1f}" if isinstance(dist_m, (int, float)) else "?"
retired = "yes" if it.get("retired") else ""
output += f"{gid:<14} {gtype:<8} {name:<32} {str(default_for):<8} {acts:<6} {dist_km:<10} {retired:<8}\n"
return output
@@ -0,0 +1,214 @@
"""
Power curve MCP tools for Intervals.icu.
This module contains tools for retrieving athlete power curve data.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
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)
def _build_curves_param(
this_season: bool,
last_season: bool,
start_date: str | None,
end_date: str | None,
) -> list[str]:
"""Build the curves query parameter list based on user selections.
Args:
this_season: Whether to include this season's curve.
last_season: Whether to include last season's curve.
start_date: Optional start date for a custom date range curve.
end_date: Optional end date for a custom date range curve.
Returns:
List of curve identifiers for the API request.
"""
curves: list[str] = []
if this_season:
curves.append("s0")
if last_season:
curves.append("s1")
if start_date and end_date:
curves.append(f"r.{start_date}.{end_date}")
return curves
def _validate_dates(start_date: str | None, end_date: str | None) -> str | None:
"""Validate that start_date and end_date are either both provided or both absent.
Returns:
An error message if validation fails, otherwise None.
"""
if (start_date is None) != (end_date is None):
return "Error: Both start_date and end_date must be provided together for a custom date range."
if start_date and end_date:
try:
s = datetime.strptime(start_date, "%Y-%m-%d")
e = datetime.strptime(end_date, "%Y-%m-%d")
if s >= e:
return "Error: start_date must be before end_date."
except ValueError:
return "Error: Dates must be in YYYY-MM-DD format."
return None
def _extract_curve_data(
curve: dict[str, Any],
durations: list[int],
include_normalised: bool,
) -> dict[str, Any]:
"""Extract power data for requested durations from a single curve.
Args:
curve: A single curve object from the API response.
durations: List of durations in seconds to extract.
include_normalised: Whether to include W/kg data.
Returns:
Dictionary with curve metadata and extracted data points.
"""
secs = curve.get("secs", [])
values = curve.get("values", [])
activity_ids = curve.get("activity_id", [])
watts_per_kg = curve.get("watts_per_kg", [])
wkg_activity_ids = curve.get("wkg_activity_id", [])
# Build a lookup from seconds to index for efficient access
sec_to_idx: dict[int, int] = {s: i for i, s in enumerate(secs)}
data_points: list[dict[str, Any]] = []
for dur in durations:
idx = sec_to_idx.get(dur)
if idx is None or idx >= len(values):
continue
point: dict[str, Any] = {
"secs": dur,
"watts": values[idx],
"activity_id": (
activity_ids[idx]
if idx < len(activity_ids) and activity_ids[idx] is not None
else ""
),
}
if include_normalised and idx < len(watts_per_kg):
point["watts_per_kg"] = round(watts_per_kg[idx], 2)
point["wkg_activity_id"] = (
wkg_activity_ids[idx]
if idx < len(wkg_activity_ids)
and wkg_activity_ids[idx] is not None
else ""
)
data_points.append(point)
return {
"id": curve.get("id", ""),
"label": curve.get("label", curve.get("id", "")),
"start": curve.get("start_date_local", ""),
"end": curve.get("end_date_local", ""),
"data_points": data_points,
}
@mcp.tool()
async def get_athlete_power_curves(
activity_type: str = "Ride",
durations: list[int] | None = None,
indoor_outdoor: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
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.
Returns best power output for selected durations across specified time periods.
Uses FFT power computation. Power values are in watts.
Args:
activity_type: Activity type (e.g. "Ride", "Run", "VirtualRide"). Default is "Ride".
durations: Durations in seconds to include. Default is [5, 15, 30, 60, 120, 300, 600, 1200, 3600]
indoor_outdoor: Filter by location — "indoor" or "outdoor". Omit for no filtering.
start_date: Start date (YYYY-MM-DD) for custom date range curve. Must be used with end_date.
end_date: End date (YYYY-MM-DD) for custom date range curve. Must be used with start_date.
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
if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"):
return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted."
date_error = _validate_dates(start_date, end_date)
if date_error:
return date_error
curves = _build_curves_param(this_season, last_season, start_date, end_date)
if not curves:
return "Error: At least one curve must be selected (this_season, last_season, or a date range)."
params: dict[str, Any] = {
"curves": curves,
"type": activity_type,
"includeRanks": False,
}
if indoor_outdoor:
params["filters"] = json.dumps(
[{"field_id": "indoor", "value": indoor_outdoor, "id": 1}]
)
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/power-curves",
params=params,
api_key=api_key,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching power curves: {error_message}"
# Response has a "list" key containing curve objects
curve_list: list[dict[str, Any]] = []
if isinstance(result, dict):
curve_list = result.get("list", [])
elif isinstance(result, list):
curve_list = result
if not curve_list:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
extracted: list[dict[str, Any]] = []
for curve in curve_list:
if isinstance(curve, dict):
extracted.append(_extract_curve_data(curve, durations, include_normalised))
if not extracted:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
return format_power_curves(extracted, activity_type, include_normalised)
@@ -0,0 +1,71 @@
"""
Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_athlete_id, 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.
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
start_date, end_date = resolve_date_params(start_date, end_date)
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching wellness data: {result.get('message')}"
if not result:
return (
f"No wellness data found for athlete {athlete_id_to_use} in the specified date range."
)
wellness_summary = "Wellness Data:\n\n"
if isinstance(result, dict):
for date_str, data in result.items():
if isinstance(data, dict) and "date" not in data:
data["date"] = date_str
wellness_summary += format_wellness_entry(data, include_all_fields=include_all_fields) + "\n\n"
elif isinstance(result, list):
for entry in result:
if isinstance(entry, dict):
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary
+64
View File
@@ -0,0 +1,64 @@
"""
Date utility functions for Intervals.icu MCP Server.
This module provides helper functions for date parsing and default date calculations.
"""
from datetime import datetime, timedelta
def get_default_start_date(days_ago: int = 30) -> str:
"""
Get a default start date string in YYYY-MM-DD format.
Args:
days_ago: Number of days ago from today. Defaults to 30.
Returns:
Date string in YYYY-MM-DD format.
"""
return (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d")
def get_default_end_date() -> str:
"""
Get today's date string in YYYY-MM-DD format.
Returns:
Date string in YYYY-MM-DD format.
"""
return datetime.now().strftime("%Y-%m-%d")
def get_default_future_end_date(days_ahead: int = 30) -> str:
"""
Get a default future end date string in YYYY-MM-DD format.
Args:
days_ahead: Number of days ahead from today. Defaults to 30.
Returns:
Date string in YYYY-MM-DD format.
"""
return (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
def parse_date_range(
start_date: str | None, end_date: str | None, default_start_days_ago: int = 30
) -> tuple[str, str]:
"""
Parse and validate a date range, providing defaults if needed.
Args:
start_date: Start date in YYYY-MM-DD format (optional).
end_date: End date in YYYY-MM-DD format (optional).
default_start_days_ago: Number of days ago for default start date. Defaults to 30.
Returns:
Tuple of (start_date, end_date) as strings in YYYY-MM-DD format.
"""
if not start_date:
start_date = get_default_start_date(default_start_days_ago)
if not end_date:
end_date = get_default_end_date()
return start_date, end_date
@@ -0,0 +1,660 @@
"""
Formatting utilities for Intervals.icu MCP Server
This module contains formatting functions for handling data from the Intervals.icu API.
"""
import json
from datetime import datetime
from typing import Any
class _KeyTracker(dict):
"""A dict wrapper that records which keys are accessed."""
def __init__(self, data: dict[str, Any]) -> None:
super().__init__(data)
self.accessed: set[str] = set()
def get(self, key: str, default: Any = None) -> Any:
self.accessed.add(key)
return super().get(key, default)
def __getitem__(self, key: str) -> Any:
self.accessed.add(key)
return super().__getitem__(key)
def __contains__(self, key: object) -> bool:
if isinstance(key, str):
self.accessed.add(key)
return super().__contains__(key)
def format_activity_summary(activity: dict[str, Any]) -> str:
"""Format an activity into a readable string."""
start_time = activity.get("startTime", activity.get("start_date", "Unknown"))
if isinstance(start_time, str) and len(start_time) > 10:
# Format datetime if it's a full ISO string
try:
dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
start_time = dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
pass
rpe = activity.get("perceived_exertion", None)
if rpe is None:
rpe = activity.get("icu_rpe", "N/A")
if isinstance(rpe, (int, float)):
rpe = f"{rpe}/10"
feel = activity.get("feel", "N/A")
if isinstance(feel, int):
feel = f"{feel}/5"
# Gear (bike, shoes) - ICU activity payloads include the gear ID but not the
# gear name (which lives in /athlete/{id}/gear). The tools.gear module
# resolves the name and injects it as `_resolved_gear_name` before this
# formatter runs. Prefer the resolved name; otherwise fall back to whatever
# the raw payload provides (typically just an ID).
resolved_name = activity.get("_resolved_gear_name")
gear_raw = activity.get("gear")
if resolved_name:
gear_name = resolved_name
if isinstance(gear_raw, dict):
gear_id = gear_raw.get("id", activity.get("gear_id", "N/A"))
else:
gear_id = activity.get("gear_id", "N/A")
elif isinstance(gear_raw, dict):
gear_name = gear_raw.get("name") or gear_raw.get("display_name") or "N/A"
gear_id = gear_raw.get("id", "N/A")
else:
gear_name = activity.get("gear_name", "N/A")
gear_id = activity.get("gear_id", "N/A")
return f"""
Activity: {activity.get("name", "Unnamed")}
ID: {activity.get("id", "N/A")}
Type: {activity.get("type", "Unknown")}
Date: {start_time}
Description: {activity.get("description", "N/A")}
Distance: {activity.get("distance", 0)} meters
Duration: {activity.get("duration", activity.get("elapsed_time", 0))} seconds
Moving Time: {activity.get("moving_time", "N/A")} seconds
Elevation Gain: {activity.get("elevationGain", activity.get("total_elevation_gain", 0))} meters
Elevation Loss: {activity.get("total_elevation_loss", "N/A")} meters
Power Data:
Average Power: {activity.get("avgPower", activity.get("icu_average_watts", activity.get("average_watts", "N/A")))} watts
Weighted Avg Power: {activity.get("icu_weighted_avg_watts", "N/A")} watts
Training Load: {activity.get("trainingLoad", activity.get("icu_training_load", "N/A"))}
FTP: {activity.get("icu_ftp", "N/A")} watts
Kilojoules: {activity.get("icu_joules", "N/A")}
Intensity: {activity.get("icu_intensity", "N/A")}
Power:HR Ratio: {activity.get("icu_power_hr", "N/A")}
Variability Index: {activity.get("icu_variability_index", "N/A")}
Heart Rate Data:
Average Heart Rate: {activity.get("avgHr", activity.get("average_heartrate", "N/A"))} bpm
Max Heart Rate: {activity.get("max_heartrate", "N/A")} bpm
LTHR: {activity.get("lthr", "N/A")} bpm
Resting HR: {activity.get("icu_resting_hr", "N/A")} bpm
Decoupling: {activity.get("decoupling", "N/A")}
Other Metrics:
Cadence: {activity.get("average_cadence", "N/A")} rpm
Calories burned: {activity.get("calories", "N/A")} kcal
Average Speed: {activity.get("average_speed", "N/A")} m/s
Max Speed: {activity.get("max_speed", "N/A")} m/s
Average Stride: {activity.get("average_stride", "N/A")}
L/R Balance: {activity.get("avg_lr_balance", "N/A")}
Weight: {activity.get("icu_weight", "N/A")} kg
RPE: {rpe}
Session RPE: {activity.get("session_rpe", "N/A")}
Feel: {feel}
Environment:
Trainer: {activity.get("trainer", "N/A")}
Average Temp: {activity.get("average_temp", "N/A")}°C
Min Temp: {activity.get("min_temp", "N/A")}°C
Max Temp: {activity.get("max_temp", "N/A")}°C
Avg Wind Speed: {activity.get("average_wind_speed", "N/A")} km/h
Headwind %: {activity.get("headwind_percent", "N/A")}%
Tailwind %: {activity.get("tailwind_percent", "N/A")}%
Training Metrics:
Fitness (CTL): {activity.get("icu_ctl", "N/A")}
Fatigue (ATL): {activity.get("icu_atl", "N/A")}
TRIMP: {activity.get("trimp", "N/A")}
Polarization Index: {activity.get("polarization_index", "N/A")}
Power Load: {activity.get("power_load", "N/A")}
HR Load: {activity.get("hr_load", "N/A")}
Pace Load: {activity.get("pace_load", "N/A")}
Efficiency Factor: {activity.get("icu_efficiency_factor", "N/A")}
Device Info:
Device: {activity.get("device_name", "N/A")}
Power Meter: {activity.get("power_meter", "N/A")}
File Type: {activity.get("file_type", "N/A")}
Gear:
Name: {gear_name}
ID: {gear_id}
"""
def format_workout(workout: dict[str, Any]) -> str:
"""Format a workout into a readable string."""
return f"""
Workout: {workout.get("name", "Unnamed")}
Description: {workout.get("description", "No description")}
Sport: {workout.get("sport", "Unknown")}
Duration: {workout.get("duration", 0)} seconds
TSS: {workout.get("tss", "N/A")}
Intervals: {len(workout.get("intervals", []))}
"""
def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
"""Format training metrics section."""
training_metrics = []
for k, label in [
("ctl", "Fitness (CTL)"),
("atl", "Fatigue (ATL)"),
("rampRate", "Ramp Rate"),
("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"),
]:
if entries.get(k) is not None:
training_metrics.append(f"- {label}: {entries[k]}")
return training_metrics
def _format_sport_info(entries: dict[str, Any]) -> list[str]:
"""Format sport-specific info section."""
sport_info_list = []
if entries.get("sportInfo"):
for sport in entries.get("sportInfo", []):
if isinstance(sport, dict) and sport.get("eftp") is not None:
sport_info_list.append(f"- {sport.get('type')}: eFTP = {sport['eftp']}")
return sport_info_list
def _format_vital_signs(entries: dict[str, Any]) -> list[str]:
"""Format vital signs section."""
vital_signs = []
for k, label, unit in [
("weight", "Weight", "kg"),
("restingHR", "Resting HR", "bpm"),
("hrv", "HRV", ""),
("hrvSDNN", "HRV SDNN", ""),
("avgSleepingHR", "Average Sleeping HR", "bpm"),
("spO2", "SpO2", "%"),
("systolic", "Systolic BP", ""),
("diastolic", "Diastolic BP", ""),
("respiration", "Respiration", "breaths/min"),
("bloodGlucose", "Blood Glucose", "mmol/L"),
("lactate", "Lactate", "mmol/L"),
("vo2max", "VO2 Max", "ml/kg/min"),
("bodyFat", "Body Fat", "%"),
("abdomen", "Abdomen", "cm"),
("baevskySI", "Baevsky Stress Index", ""),
]:
if entries.get(k) is not None:
value = entries[k]
if k == "systolic" and entries.get("diastolic") is not None:
vital_signs.append(
f"- Blood Pressure: {entries['systolic']}/{entries['diastolic']} mmHg"
)
elif k not in ("systolic", "diastolic"):
vital_signs.append(f"- {label}: {value}{(' ' + unit) if unit else ''}")
return vital_signs
def _format_sleep_recovery(entries: dict[str, Any]) -> list[str]:
"""Format sleep and recovery section."""
sleep_lines = []
sleep_hours = None
if entries.get("sleepSecs") is not None:
sleep_hours = f"{entries['sleepSecs'] / 3600:.2f}"
elif entries.get("sleepHours") is not None:
sleep_hours = f"{entries['sleepHours']}"
if sleep_hours is not None:
sleep_lines.append(f" Sleep: {sleep_hours} hours")
if entries.get("sleepQuality") is not None:
quality_value = entries["sleepQuality"]
quality_labels = {1: "Great", 2: "Good", 3: "Average", 4: "Poor"}
quality_text = quality_labels.get(quality_value, str(quality_value))
sleep_lines.append(f" Sleep Quality: {quality_value} ({quality_text})")
if entries.get("sleepScore") is not None:
sleep_lines.append(f" Device Sleep Score: {entries['sleepScore']}/100")
if entries.get("readiness") is not None:
sleep_lines.append(f" Readiness: {entries['readiness']}/10")
return sleep_lines
def _format_menstrual_tracking(entries: dict[str, Any]) -> list[str]:
"""Format menstrual tracking section."""
menstrual_lines = []
if entries.get("menstrualPhase") is not None:
menstrual_lines.append(f" Menstrual Phase: {str(entries['menstrualPhase']).capitalize()}")
if entries.get("menstrualPhasePredicted") is not None:
menstrual_lines.append(
f" Predicted Phase: {str(entries['menstrualPhasePredicted']).capitalize()}"
)
return menstrual_lines
def _format_subjective_feelings(entries: dict[str, Any]) -> list[str]:
"""Format subjective feelings section."""
subjective_lines = []
for k, label in [
("soreness", "Soreness"),
("fatigue", "Fatigue"),
("stress", "Stress"),
("mood", "Mood"),
("motivation", "Motivation"),
("injury", "Injury Level"),
]:
if entries.get(k) is not None:
subjective_lines.append(f" {label}: {entries[k]}/10")
return subjective_lines
def _format_nutrition_hydration(entries: dict[str, Any]) -> list[str]:
"""Format nutrition and hydration section.
Handles both legacy fields (kcalConsumed, hydrationVolume) and the native
macro fields from the Intervals.icu API (carbohydrates, protein,
fatTotal). All fields are rendered conditionally — a null/missing value
hides the corresponding line for backward compatibility with older
wellness records.
"""
nutrition_lines = []
for k, label, unit in [
("kcalConsumed", "Calories Consumed", ""),
("carbohydrates", "Carbohydrates", "g"),
("protein", "Protein", "g"),
("fatTotal", "Fat", "g"),
("hydrationVolume", "Hydration Volume", ""),
]:
if entries.get(k) is not None:
suffix = f" {unit}" if unit else ""
nutrition_lines.append(f"- {label}: {entries[k]}{suffix}")
if entries.get("hydration") is not None:
nutrition_lines.append(f" Hydration Score: {entries['hydration']}/10")
return nutrition_lines
def _format_other_fields(entries: dict[str, Any], known_keys: set[str]) -> list[str]:
"""Format any fields not already handled by the standard formatting sections."""
other_lines = []
for key, value in entries.items():
if key not in known_keys and value is not None:
if isinstance(value, (dict, list)):
other_lines.append(f"- {key}: {json.dumps(value)}")
else:
other_lines.append(f"- {key}: {value}")
return other_lines
def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = False) -> str:
"""Format wellness entry data into a readable string.
Formats various wellness metrics including training metrics, vital signs,
sleep data, menstrual tracking, subjective feelings, nutrition, and activity.
Args:
entries: Dictionary containing wellness data fields such as:
- Training metrics: ctl, atl, rampRate, ctlLoad, atlLoad
- Vital signs: weight, restingHR, hrv, hrvSDNN, avgSleepingHR, spO2,
systolic, diastolic, respiration, bloodGlucose, lactate, vo2max,
bodyFat, abdomen, baevskySI
- Sleep: sleepSecs, sleepHours, sleepQuality, sleepScore, readiness
- Menstrual: menstrualPhase, menstrualPhasePredicted
- Subjective: soreness, fatigue, stress, mood, motivation, injury
- Nutrition: kcalConsumed, carbohydrates, protein, fatTotal, hydrationVolume, hydration
- Activity: steps
- Other: comments, locked, date
include_all_fields: If True, any fields not covered by the standard
sections are appended under an "Other Fields" heading (default False).
Returns:
A formatted string representation of the wellness entry.
"""
if include_all_fields:
entries = _KeyTracker(entries)
# Mark metadata/internal keys so they don't appear in "Other Fields"
entries.get("date")
entries.get("updated")
entries.get("tempWeight")
entries.get("tempRestingHR")
lines = ["Wellness Data:"]
lines.append(f"Date: {entries.get('id', 'N/A')}")
lines.append("")
training_metrics = _format_training_metrics(entries)
if training_metrics:
lines.append("Training Metrics:")
lines.extend(training_metrics)
lines.append("")
sport_info_list = _format_sport_info(entries)
if sport_info_list:
lines.append("Sport-Specific Info:")
lines.extend(sport_info_list)
lines.append("")
vital_signs = _format_vital_signs(entries)
if vital_signs:
lines.append("Vital Signs:")
lines.extend(vital_signs)
lines.append("")
sleep_lines = _format_sleep_recovery(entries)
if sleep_lines:
lines.append("Sleep & Recovery:")
lines.extend(sleep_lines)
lines.append("")
menstrual_lines = _format_menstrual_tracking(entries)
if menstrual_lines:
lines.append("Menstrual Tracking:")
lines.extend(menstrual_lines)
lines.append("")
subjective_lines = _format_subjective_feelings(entries)
if subjective_lines:
lines.append("Subjective Feelings:")
lines.extend(subjective_lines)
lines.append("")
nutrition_lines = _format_nutrition_hydration(entries)
if nutrition_lines:
lines.append("Nutrition & Hydration:")
lines.extend(nutrition_lines)
lines.append("")
if entries.get("steps") is not None:
lines.append("Activity:")
lines.append(f"- Steps: {entries['steps']}")
lines.append("")
if entries.get("comments"):
lines.append(f"Comments: {entries['comments']}")
if "locked" in entries:
lines.append(f"Status: {'Locked' if entries.get('locked') else 'Unlocked'}")
if include_all_fields and isinstance(entries, _KeyTracker):
other_lines = _format_other_fields(entries, entries.accessed)
if other_lines:
lines.append("")
lines.append("Other Fields:")
lines.extend(other_lines)
return "\n".join(lines)
def format_event_summary(event: dict[str, Any]) -> str:
"""Format a basic event summary into a readable string."""
# Update to check for "date" if "start_date_local" is not provided
event_date = event.get("start_date_local", event.get("date", "Unknown"))
event_type = "Workout" if event.get("workout") else "Race" if event.get("race") else "Other"
event_name = event.get("name", "Unnamed")
event_id = event.get("id", "N/A")
event_desc = event.get("description", "No description")
return f"""Date: {event_date}
ID: {event_id}
Type: {event_type}
Name: {event_name}
Description: {event_desc}"""
def format_event_details(event: dict[str, Any]) -> str:
"""Format detailed event information into a readable string."""
event_details = f"""Event Details:
ID: {event.get("id", "N/A")}
Date: {event.get("date", "Unknown")}
Name: {event.get("name", "Unnamed")}
Description: {event.get("description", "No description")}"""
# Check if it's a workout-based event
if "workout" in event and event["workout"]:
workout = event["workout"]
event_details += f"""
Workout Information:
Workout ID: {workout.get("id", "N/A")}
Sport: {workout.get("sport", "Unknown")}
Duration: {workout.get("duration", 0)} seconds
TSS: {workout.get("tss", "N/A")}"""
# Include interval count if available
if "intervals" in workout and isinstance(workout["intervals"], list):
event_details += f"""
Intervals: {len(workout["intervals"])}"""
# Check if it's a race
if event.get("race"):
event_details += f"""
Race Information:
Priority: {event.get("priority", "N/A")}
Result: {event.get("result", "N/A")}"""
# Include calendar information
if "calendar" in event:
cal = event["calendar"]
event_details += f"""
Calendar: {cal.get("name", "N/A")}"""
return event_details
def format_activity_message(message: dict[str, Any]) -> str:
"""Format an activity message/note into a readable string."""
created = message.get("created", "Unknown")
if isinstance(created, str) and len(created) > 10:
try:
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
created = dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
pass
return f"""Author: {message.get("name", "Unknown")}
Date: {created}
Type: {message.get("type", "TEXT")}
Content: {message.get("content", "")}"""
def format_custom_item_details(item: dict[str, Any]) -> str:
"""Format detailed custom item information into a readable string."""
lines = ["Custom Item Details:", ""]
lines.append(f"ID: {item.get('id', 'N/A')}")
lines.append(f"Name: {item.get('name', 'N/A')}")
lines.append(f"Type: {item.get('type', 'N/A')}")
if item.get("description"):
lines.append(f"Description: {item['description']}")
if item.get("visibility"):
lines.append(f"Visibility: {item['visibility']}")
if item.get("index") is not None:
lines.append(f"Index: {item['index']}")
if item.get("hide_script") is not None:
lines.append(f"Hide Script: {item['hide_script']}")
if item.get("content"):
lines.append(f"Content: {json.dumps(item['content'], indent=2)}")
return "\n".join(lines)
def format_intervals(intervals_data: dict[str, Any]) -> str:
"""Format intervals data into a readable string with all available fields.
Args:
intervals_data: The intervals data from the Intervals.icu API
Returns:
A formatted string representation of the intervals data
"""
# Format basic intervals information
result = f"""Intervals Analysis:
ID: {intervals_data.get("id", "N/A")}
Analyzed: {intervals_data.get("analyzed", "N/A")}
"""
# Format individual intervals
if "icu_intervals" in intervals_data and intervals_data["icu_intervals"]:
result += "Individual Intervals:\n\n"
for i, interval in enumerate(intervals_data["icu_intervals"], 1):
result += f"""[{i}] {interval.get("label", f"Interval {i}")} ({interval.get("type", "Unknown")})
Duration: {interval.get("elapsed_time", 0)} seconds (moving: {interval.get("moving_time", 0)} seconds)
Distance: {interval.get("distance", 0)} meters
Start-End Indices: {interval.get("start_index", 0)}-{interval.get("end_index", 0)}
Power Metrics:
Average Power: {interval.get("average_watts", 0)} watts ({interval.get("average_watts_kg", 0)} W/kg)
Max Power: {interval.get("max_watts", 0)} watts ({interval.get("max_watts_kg", 0)} W/kg)
Weighted Avg Power: {interval.get("weighted_average_watts", 0)} watts
Intensity: {interval.get("intensity", 0)}
Training Load: {interval.get("training_load", 0)}
Joules: {interval.get("joules", 0)}
Joules > FTP: {interval.get("joules_above_ftp", 0)}
Power Zone: {interval.get("zone", "N/A")} ({interval.get("zone_min_watts", 0)}-{interval.get("zone_max_watts", 0)} watts)
W' Balance: Start {interval.get("wbal_start", 0)}, End {interval.get("wbal_end", 0)}
L/R Balance: {interval.get("avg_lr_balance", 0)}
Variability: {interval.get("w5s_variability", 0)}
Torque: Avg {interval.get("average_torque", 0)}, Min {interval.get("min_torque", 0)}, Max {interval.get("max_torque", 0)}
Heart Rate & Metabolic:
Heart Rate: Avg {interval.get("average_heartrate", 0)}, Min {interval.get("min_heartrate", 0)}, Max {interval.get("max_heartrate", 0)} bpm
Decoupling: {interval.get("decoupling", 0)}
DFA α1: {interval.get("average_dfa_a1", 0)}
Respiration: {interval.get("average_respiration", 0)} breaths/min
EPOC: {interval.get("average_epoc", 0)}
SmO2: {interval.get("average_smo2", 0)}% / {interval.get("average_smo2_2", 0)}%
THb: {interval.get("average_thb", 0)} / {interval.get("average_thb_2", 0)}
Speed & Cadence:
Speed: Avg {interval.get("average_speed", 0)}, Min {interval.get("min_speed", 0)}, Max {interval.get("max_speed", 0)} m/s
GAP: {interval.get("gap", 0)} m/s
Cadence: Avg {interval.get("average_cadence", 0)}, Min {interval.get("min_cadence", 0)}, Max {interval.get("max_cadence", 0)} rpm
Stride: {interval.get("average_stride", 0)}
Elevation & Environment:
Elevation Gain: {interval.get("total_elevation_gain", 0)} meters
Altitude: Min {interval.get("min_altitude", 0)}, Max {interval.get("max_altitude", 0)} meters
Gradient: {interval.get("average_gradient", 0)}%
Temperature: {interval.get("average_temp", 0)}°C (Weather: {interval.get("average_weather_temp", 0)}°C, Feels like: {interval.get("average_feels_like", 0)}°C)
Wind: Speed {interval.get("average_wind_speed", 0)} km/h, Gust {interval.get("average_wind_gust", 0)} km/h, Direction {interval.get("prevailing_wind_deg", 0)}°
Headwind: {interval.get("headwind_percent", 0)}%, Tailwind: {interval.get("tailwind_percent", 0)}%
"""
# Format interval groups
if "icu_groups" in intervals_data and intervals_data["icu_groups"]:
result += "Interval Groups:\n\n"
for i, group in enumerate(intervals_data["icu_groups"], 1):
result += f"""Group: {group.get("id", f"Group {i}")} (Contains {group.get("count", 0)} intervals)
Duration: {group.get("elapsed_time", 0)} seconds (moving: {group.get("moving_time", 0)} seconds)
Distance: {group.get("distance", 0)} meters
Start-End Indices: {group.get("start_index", 0)}-N/A
Power: Avg {group.get("average_watts", 0)} watts ({group.get("average_watts_kg", 0)} W/kg), Max {group.get("max_watts", 0)} watts
W. Avg Power: {group.get("weighted_average_watts", 0)} watts, Intensity: {group.get("intensity", 0)}
Heart Rate: Avg {group.get("average_heartrate", 0)}, Max {group.get("max_heartrate", 0)} bpm
Speed: Avg {group.get("average_speed", 0)}, Max {group.get("max_speed", 0)} m/s
Cadence: Avg {group.get("average_cadence", 0)}, Max {group.get("max_cadence", 0)} rpm
"""
return result
def _format_duration_label(secs: int) -> str:
"""Format seconds into a concise human-readable label (e.g. 5s, 2m, 1h)."""
if secs < 60:
return f"{secs}s"
if secs < 3600:
mins = secs // 60
remainder = secs % 60
if remainder:
return f"{mins}m{remainder}s"
return f"{mins}m"
hours = secs // 3600
remainder = (secs % 3600) // 60
if remainder:
return f"{hours}h{remainder}m"
return f"{hours}h"
def format_power_curves(
curves: list[dict[str, Any]],
activity_type: str,
include_normalised: bool,
) -> str:
"""Format extracted power curve data into a concise readable string.
Args:
curves: List of extracted curve data dicts with id, label, data_points.
activity_type: The activity type used for the query.
include_normalised: Whether W/kg data is included.
Returns:
A formatted string representation of the power curves.
"""
lines: list[str] = [f"Power Curves ({activity_type}):", ""]
for curve in curves:
label = curve.get("label", curve.get("id", "Unknown"))
start = curve.get("start", "")
end = curve.get("end", "")
date_range = ""
if start and end:
# Trim time portion if present
start_short = start[:10] if len(start) > 10 else start
end_short = end[:10] if len(end) > 10 else end
date_range = f" ({start_short} to {end_short})"
lines.append(f"{label}{date_range}:")
data_points = curve.get("data_points", [])
if not data_points:
lines.append(" No data available for requested durations.")
lines.append("")
continue
for point in data_points:
dur_label = _format_duration_label(point["secs"])
watts = point.get("watts")
aid = point.get("activity_id", "")
parts = [f" {dur_label}: {watts}W"]
if include_normalised and "watts_per_kg" in point:
parts.append(f"{point['watts_per_kg']:.2f}W/kg")
wkg_aid = point.get("wkg_activity_id", "")
if wkg_aid and wkg_aid != aid:
parts.append(f"[{aid}|wkg:{wkg_aid}]")
else:
parts.append(f"[{aid}]")
else:
parts.append(f"[{aid}]")
lines.append(" ".join(parts))
lines.append("")
return "\n".join(lines)
+590
View File
@@ -0,0 +1,590 @@
"""
Type definitions for Intervals.icu MCP Server.
This module contains dataclasses and enums for representing workout data structures
used in the Intervals.icu API, including workout steps, values, and documentation.
Also includes enums for server configuration.
"""
from dataclasses import dataclass
from typing import List, Dict, Optional, Any, Union
from enum import Enum, StrEnum
import json
__all__ = [
"Option",
"WorkoutTarget",
"HrTarget",
"Intensity",
"PaceUnits",
"ValueUnits",
"TransportAliases",
"Value",
"Step",
"SportSettings",
"WorkoutDoc",
]
class Option(Enum):
"""Enumeration of workout option types."""
CATEGORY = "category"
POOL_LENGTH = "pool_length"
POWER = "power"
class WorkoutTarget(Enum):
"""Enumeration of workout target types."""
AUTO = "AUTO"
POWER = "POWER"
HR = "HR"
PACE = "PACE"
class HrTarget(Enum):
"""Enumeration of heart rate target averaging methods."""
LAP = "lap"
INSTANT = "1s"
THREE_SECOND = "3s"
TEN_SECOND = "10s"
THIRTY_SECOND = "30s"
class Intensity(Enum):
"""Enumeration of workout step intensity types."""
ACTIVE = "active"
REST = "rest"
WARMUP = "warmup"
COOLDOWN = "cooldown"
RECOVERY = "recovery"
INTERVAL = "interval"
OTHER = "other"
class PaceUnits(Enum):
"""Enumeration of pace unit types for swimming and running."""
SECS_100M = "SECS_100M"
SECS_100Y = "SECS_100Y"
MINS_KM = "MINS_KM"
MINS_MILE = "MINS_MILE"
SECS_500M = "SECS_500M"
class ValueUnits(Enum):
"""Enumeration of value unit types for workout steps (power, heart rate, pace, cadence)."""
PERCENT_MMP = "%mmp"
PERCENT_HR = "%hr"
PERCENT_LTHR = "%lthr"
PERCENT_PACE = "%pace"
POWER_ZONE = "power_zone"
HR_ZONE = "hr_zone"
PACE_ZONE = "pace_zone"
WATTS = "w"
PERCENT_FTP = "%ftp"
CADENCE = "cadence"
MINS_KM = "MINS_KM"
MINS_MILE = "MINS_MILE"
SECS_100M = "SECS_100M"
SECS_500M = "SECS_500M"
class TransportAliases(StrEnum):
"""Enumeration of supported MCP transport types."""
STDIO = "stdio"
SSE = "sse"
HTTP = "http"
STREAMABLE_HTTP = "streamable-http"
def float_to_str(value: float) -> str:
"""Format the value without decimals if it's a whole number."""
return str(int(value)) if value.is_integer() else str(value)
@dataclass
class Value:
"""Represents a value with units for workout step intensity (power, heart rate, pace, cadence).
Can represent a single value, a range (start-end), or a ramp. Supports various unit types
including percentages, zones, and absolute values.
"""
value: Optional[float] = None
start: Optional[float] = None
end: Optional[float] = None
units: Optional[ValueUnits] = None
target: Optional[HrTarget] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert Value instance to dictionary for JSON serialization."""
data: Dict[str, Any] = {}
if self.value is not None:
data["value"] = self.value
if self.start is not None:
data["start"] = self.start
if self.end is not None:
data["end"] = self.end
if self.units is not None:
data["units"] = self.units.value
if self.target is not None:
data["target"] = self.target.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Value":
"""Create Value instance from dictionary."""
kwargs = {}
if "value" in data:
kwargs["value"] = data["value"]
if "start" in data:
kwargs["start"] = data["start"]
if "end" in data:
kwargs["end"] = data["end"]
if "units" in data:
kwargs["units"] = ValueUnits(data["units"])
if "target" in data:
kwargs["target"] = HrTarget(data["target"])
return cls(**kwargs)
def to_json(self) -> str:
"""Convert Value instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "Value":
"""Create Value instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def _format_value(self, value: float) -> str:
if self.units in [
ValueUnits.PERCENT_HR,
ValueUnits.PERCENT_MMP,
ValueUnits.PERCENT_LTHR,
ValueUnits.PERCENT_PACE,
ValueUnits.PERCENT_FTP,
]:
return f"{float_to_str(value)}%"
if self.units in [ValueUnits.POWER_ZONE, ValueUnits.HR_ZONE, ValueUnits.PACE_ZONE]:
return f"Z{float_to_str(value)}"
if self.units in [ValueUnits.WATTS]:
return f"{float_to_str(value)}W"
if self.units in [ValueUnits.CADENCE]:
return f"{float_to_str(value)}rpm"
return float_to_str(value)
def _format_units(self) -> str:
"""Format units into a human-readable string using dictionary mapping."""
units_map = {
ValueUnits.PERCENT_HR: "HR",
ValueUnits.HR_ZONE: "HR",
ValueUnits.PERCENT_MMP: "MMP",
ValueUnits.PERCENT_LTHR: "LTHR",
ValueUnits.PERCENT_PACE: "Pace",
ValueUnits.PACE_ZONE: "Pace",
ValueUnits.PERCENT_FTP: "ftp",
ValueUnits.POWER_ZONE: "W",
ValueUnits.CADENCE: "Cadence",
}
if self.units is None:
return ""
return units_map.get(self.units, "")
def __str__(self) -> str:
val = ""
if self.start is not None and self.end is not None:
val += f"{self._format_value(self.start)}-{self._format_value(self.end)} "
if self.value is not None:
val += f"{self._format_value(self.value)} "
if self.units is not None:
val += f"{self._format_units()} "
if self.target is not None:
val += f"hr={self.target.value} "
return val.strip()
@dataclass
class Step: # pylint: disable=too-many-instance-attributes
"""Represents a single step in a workout.
A step can be a warmup, cooldown, interval, or repeat block. It can specify
duration, distance, intensity targets (power, heart rate, pace, cadence), and
contain nested steps for repeats.
"""
text: Optional[str] = None
text_locale: Optional[Dict[str, str]] = None
duration: Optional[int] = None
distance: Optional[float] = None
until_lap_press: Optional[bool] = None
reps: Optional[int] = None
warmup: Optional[bool] = None
cooldown: Optional[bool] = None
intensity: Optional[Intensity] = None
steps: Optional[List["Step"]] = None
ramp: Optional[bool] = None
freeride: Optional[bool] = None
maxeffort: Optional[bool] = None
power: Optional[Value] = None
hr: Optional[Value] = None
pace: Optional[Value] = None
cadence: Optional[Value] = None
hidepower: Optional[bool] = None
# these are filled in with actual watts, bpm etc. when resolve=true parameter is supplied to the endpoint
_power: Optional[Value] = None
_hr: Optional[Value] = None
_pace: Optional[Value] = None
_distance: Optional[float] = None
def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches
"""Convert Step instance to dictionary for JSON serialization.
Many branches are required to handle all optional fields of the Step dataclass.
"""
data: Dict[str, Any] = {}
if self.text is not None:
data["text"] = self.text
if self.text_locale is not None:
data["text_locale"] = self.text_locale
if self.duration is not None:
data["duration"] = self.duration
if self.distance is not None:
data["distance"] = self.distance
if self.until_lap_press is not None:
data["until_lap_press"] = self.until_lap_press
if self.reps is not None:
data["reps"] = self.reps
if self.warmup is not None:
data["warmup"] = self.warmup
if self.cooldown is not None:
data["cooldown"] = self.cooldown
if self.intensity is not None:
data["intensity"] = self.intensity.value
if self.steps is not None:
data["steps"] = [step.to_dict() for step in self.steps]
if self.ramp is not None:
data["ramp"] = self.ramp
if self.freeride is not None:
data["freeride"] = self.freeride
if self.maxeffort is not None:
data["maxeffort"] = self.maxeffort
if self.power is not None:
data["power"] = self.power.to_dict()
if self.hr is not None:
data["hr"] = self.hr.to_dict()
if self.pace is not None:
data["pace"] = self.pace.to_dict()
if self.cadence is not None:
data["cadence"] = self.cadence.to_dict()
if self.hidepower is not None:
data["hidepower"] = self.hidepower
if self._power is not None:
data["_power"] = self._power.to_dict()
if self._hr is not None:
data["_hr"] = self._hr.to_dict()
if self._pace is not None:
data["_pace"] = self._pace.to_dict()
if self._distance is not None:
data["_distance"] = self._distance
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Step": # pylint: disable=too-many-branches
"""Create Step instance from dictionary.
Many branches are required to handle all optional fields of the Step dataclass.
"""
kwargs = {}
if "text" in data:
kwargs["text"] = data["text"]
if "text_locale" in data:
kwargs["text_locale"] = data["text_locale"]
if "duration" in data:
kwargs["duration"] = data["duration"]
if "distance" in data:
kwargs["distance"] = data["distance"]
if "until_lap_press" in data:
kwargs["until_lap_press"] = data["until_lap_press"]
if "reps" in data:
kwargs["reps"] = data["reps"]
if "warmup" in data:
kwargs["warmup"] = data["warmup"]
if "cooldown" in data:
kwargs["cooldown"] = data["cooldown"]
if "intensity" in data:
kwargs["intensity"] = Intensity(data["intensity"])
if "steps" in data:
kwargs["steps"] = [cls.from_dict(step) for step in data["steps"]]
if "ramp" in data:
kwargs["ramp"] = data["ramp"]
if "freeride" in data:
kwargs["freeride"] = data["freeride"]
if "maxeffort" in data:
kwargs["maxeffort"] = data["maxeffort"]
if "power" in data:
kwargs["power"] = Value.from_dict(data["power"])
if "hr" in data:
kwargs["hr"] = Value.from_dict(data["hr"])
if "pace" in data:
kwargs["pace"] = Value.from_dict(data["pace"])
if "cadence" in data:
kwargs["cadence"] = Value.from_dict(data["cadence"])
if "hidepower" in data:
kwargs["hidepower"] = data["hidepower"]
if "_power" in data:
kwargs["_power"] = Value.from_dict(data["_power"])
if "_hr" in data:
kwargs["_hr"] = Value.from_dict(data["_hr"])
if "_pace" in data:
kwargs["_pace"] = Value.from_dict(data["_pace"])
if "_distance" in data:
kwargs["_distance"] = data["_distance"]
return cls(**kwargs)
def to_json(self) -> str:
"""Convert Step instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "Step":
"""Create Step instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def _format_duration(self) -> str:
"""Format duration into a human-readable string."""
if self.duration is None:
return ""
remaining_duration = self.duration
val = ""
if remaining_duration > 3600:
val += f"{remaining_duration // 3600}h"
remaining_duration %= 3600
if remaining_duration > 100 or remaining_duration == 60:
val += f"{remaining_duration // 60}m"
remaining_duration %= 60
if remaining_duration > 0:
val += f"{remaining_duration}s"
return val
def _format_distance(self) -> str:
"""Format distance into a human-readable string."""
if self.distance is None:
return ""
if self.distance < 1000:
return f"{float_to_str(self.distance)}mtr"
return f"{float_to_str(self.distance / 1000)}km"
def __str__(self) -> str:
"""Convert Step to string representation."""
return self._to_str()
def _to_str(self, nested: bool = False) -> str: # pylint: disable=too-many-branches
"""Convert Step to string representation.
Many branches are required to format all optional fields and handle different step types.
"""
val = ""
if self.reps is not None:
if nested:
raise ValueError("Nested steps not supported")
val += f"\n{self.reps}x "
else:
if not nested and self.warmup:
val += "\nWarmup\n"
if not nested and self.cooldown:
val += "\nCooldown\n"
val += ""
if self.duration is not None:
val += f"- {self._format_duration()} "
elif self.distance is not None:
val += f"- {self._format_distance()} "
if self.freeride:
val += "freeride "
if self.maxeffort:
val += "maxeffort "
if self.ramp:
val += "ramp "
if self.hidepower:
val += "hidepower "
if self.intensity is not None:
val += f"intensity={self.intensity.value} "
if self.power is not None:
val += f"{self.power} "
if self.hr is not None:
val += f"{self.hr} "
if self.pace is not None:
val += f"{self.pace} "
if self.cadence is not None:
val += f"{self.cadence} "
if self.text is not None:
val += f"{self.text} "
if self.reps is not None and self.steps is not None:
for step in self.steps:
# Using _to_str instead of __str__ because we need the nested=True arg;
# __str__ can't accept extra parameters.
val += "\n" + step._to_str(nested=True) # pylint: disable=protected-access
val += "\n"
elif not nested and (self.warmup or self.cooldown):
val += "\n"
return val
@dataclass
class SportSettings:
"""Represents sport-specific settings for a workout.
Currently empty, but can be extended with sport-specific configuration
as needed by the Intervals.icu API.
"""
def to_dict(self) -> Dict[str, Any]:
"""Convert SportSettings instance to dictionary for JSON serialization."""
return {}
@classmethod
def from_dict(cls, _data: Dict[str, Any]) -> "SportSettings":
"""Create SportSettings instance from dictionary."""
return cls()
def to_json(self) -> str:
"""Convert SportSettings instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "SportSettings":
"""Create SportSettings instance from JSON string."""
return cls.from_dict(json.loads(json_str))
@dataclass
class WorkoutDoc: # pylint: disable=too-many-instance-attributes
"""Represents a complete workout document with description, steps, and settings.
This is the main structure used to define workouts for the Intervals.icu API,
containing workout metadata, step definitions, and sport-specific settings.
Many instance attributes are required to match the Intervals.icu API schema exactly.
"""
description: Optional[str] = None
description_locale: Optional[Dict[str, str]] = None
duration: Optional[int] = None
distance: Optional[float] = None
ftp: Optional[int] = None
lthr: Optional[int] = None
threshold_pace: Optional[float] = None # meters/sec
pace_units: Optional[PaceUnits] = None
sport_settings: Optional[SportSettings] = None
category: Optional[str] = None
target: Optional[WorkoutTarget] = None
steps: Optional[List[Step]] = None
zone_times: Optional[List[Union[int, Any]]] = (
None # sometimes array of ints otherwise array of objects
)
options: Optional[Dict[str, str]] = None
locales: Optional[List[str]] = None
def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches
"""Convert WorkoutDoc instance to dictionary for JSON serialization.
Many branches are required to handle all optional fields of the WorkoutDoc dataclass.
"""
data: Dict[str, Any] = {}
if self.description is not None:
data["description"] = self.description
if self.description_locale is not None:
data["description_locale"] = self.description_locale
if self.duration is not None:
data["duration"] = self.duration
if self.distance is not None:
data["distance"] = self.distance
if self.ftp is not None:
data["ftp"] = self.ftp
if self.lthr is not None:
data["lthr"] = self.lthr
if self.threshold_pace is not None:
data["threshold_pace"] = self.threshold_pace
if self.pace_units is not None:
data["pace_units"] = self.pace_units.value
if self.sport_settings is not None:
data["sportSettings"] = self.sport_settings.to_dict() # API uses camelCase
if self.category is not None:
data["category"] = self.category
if self.target is not None:
data["target"] = self.target.value
if self.steps is not None:
data["steps"] = [step.to_dict() for step in self.steps]
if self.zone_times is not None:
data["zoneTimes"] = self.zone_times # API uses camelCase
if self.options is not None:
data["options"] = self.options
if self.locales is not None:
data["locales"] = self.locales
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "WorkoutDoc": # pylint: disable=too-many-branches
"""Create WorkoutDoc instance from dictionary.
Many branches are required to handle all optional fields of the WorkoutDoc dataclass.
"""
kwargs = {}
if "description" in data:
kwargs["description"] = data["description"]
if "description_locale" in data:
kwargs["description_locale"] = data["description_locale"]
if "duration" in data:
kwargs["duration"] = data["duration"]
if "distance" in data:
kwargs["distance"] = data["distance"]
if "ftp" in data:
kwargs["ftp"] = data["ftp"]
if "lthr" in data:
kwargs["lthr"] = data["lthr"]
if "threshold_pace" in data:
kwargs["threshold_pace"] = data["threshold_pace"]
if "pace_units" in data:
kwargs["pace_units"] = PaceUnits(data["pace_units"])
if "sportSettings" in data: # API uses camelCase
kwargs["sport_settings"] = SportSettings.from_dict(data["sportSettings"])
if "category" in data:
kwargs["category"] = data["category"]
if "target" in data:
kwargs["target"] = WorkoutTarget(data["target"])
if "steps" in data:
kwargs["steps"] = [Step.from_dict(step) for step in data["steps"]]
if "zoneTimes" in data: # API uses camelCase
kwargs["zone_times"] = data["zoneTimes"]
if "options" in data:
kwargs["options"] = data["options"]
if "locales" in data:
kwargs["locales"] = data["locales"]
return cls(**kwargs)
def to_json(self) -> str:
"""Convert WorkoutDoc instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "WorkoutDoc":
"""Create WorkoutDoc instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def __str__(self) -> str:
val = ""
if self.description is not None:
val += f"{self.description}\n"
if self.steps is not None:
for step in self.steps:
val += step.__str__() + "\n"
return val
@@ -0,0 +1,118 @@
"""
Validation utilities for Intervals.icu MCP Server
This module contains validation functions for input parameters.
"""
import re
from datetime import datetime
from intervals_mcp_server.utils.dates import parse_date_range
def validate_athlete_id(athlete_id: str) -> None:
"""Validate that an athlete ID is in the correct format.
Empty strings are allowed (meaning no default athlete ID is set).
Non-empty athlete IDs must be all digits or start with 'i' followed by digits.
Args:
athlete_id: The athlete ID to validate.
Raises:
ValueError: If the athlete ID is not in the correct format.
"""
if athlete_id and not re.fullmatch(r"i?\d+", athlete_id):
raise ValueError(
"ATHLETE_ID must be all digits (e.g. 123456) or start with 'i' followed by digits (e.g. i123456)"
)
def validate_date(date_str: str) -> str:
"""Validate that a date string is in YYYY-MM-DD format.
Args:
date_str: The date string to validate.
Returns:
The validated date string if valid.
Raises:
ValueError: If the date string is not in YYYY-MM-DD format.
"""
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError as exc:
raise ValueError("Invalid date format. Please use YYYY-MM-DD.") from exc
def resolve_athlete_id(
athlete_id: str | None, default_athlete_id: str = ""
) -> tuple[str, str | None]:
"""Resolve athlete ID from parameter or default, with error message if missing.
Args:
athlete_id: Optional athlete ID parameter.
default_athlete_id: Default athlete ID to use if athlete_id is None.
Returns:
Tuple of (athlete_id_to_use, error_message).
athlete_id_to_use will be empty string if not found.
error_message will be None if athlete_id is resolved successfully.
"""
athlete_id_to_use = athlete_id if athlete_id is not None else default_athlete_id
if not athlete_id_to_use:
return (
"",
"Error: No athlete ID provided and no default ATHLETE_ID found in environment variables.",
)
return athlete_id_to_use, None
def resolve_activity_type(name: str | None, activity_type: str | None = None) -> str:
"""Determine the activity type based on the name and provided value.
If an explicit *activity_type* is given it is returned as-is. Otherwise the
*name* is searched for common keywords to infer the type, defaulting to
``"Ride"`` when no match is found.
Args:
name: An optional activity/event name to infer the type from.
activity_type: An explicitly provided activity type.
Returns:
The resolved activity type string.
"""
if activity_type:
return activity_type
name_lower = name.lower() if name else ""
mapping = [
("Ride", ["bike", "cycle", "cycling", "ride"]),
("Run", ["run", "running", "jog", "jogging"]),
("Swim", ["swim", "swimming", "pool"]),
("Walk", ["walk", "walking", "hike", "hiking"]),
("Row", ["row", "rowing"]),
]
for workout, keywords in mapping:
if any(keyword in name_lower for keyword in keywords):
return workout
return "Ride" # Default
def resolve_date_params(
start_date: str | None,
end_date: str | None,
default_start_days_ago: int = 30,
) -> tuple[str, str]:
"""Resolve start and end date parameters with defaults.
Args:
start_date: Optional start date in YYYY-MM-DD format.
end_date: Optional end date in YYYY-MM-DD format.
default_start_days_ago: Number of days ago for default start date. Defaults to 30.
Returns:
Tuple of (start_date, end_date) as strings in YYYY-MM-DD format.
"""
return parse_date_range(start_date, end_date, default_start_days_ago)