Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s
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:
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
API client module for Intervals.icu MCP Server.
|
||||
|
||||
This module contains the HTTP client and API request handling logic.
|
||||
"""
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}."
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user