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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:07:46 -04:00
commit 935abf86d4
46 changed files with 8485 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
---
description:
globs:
alwaysApply: true
---
# API Communication Patterns
## Intervals.icu API Integration
This project communicates with the Intervals.icu API using consistent patterns defined in [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py).
## Core API Function
All API communication goes through `make_intervals_request()`:
```python
async def make_intervals_request(
url: str,
api_key: str | None = None,
params: dict[str, Any] | None = None
) -> dict[str, Any] | list[dict[str, Any]]
```
### Usage Pattern
- **URL Format**: Relative paths like `/athlete/{id}/activities`
- **Authentication**: HTTP Basic Auth with username "API_KEY" and password as the API key
- **Headers**: Includes User-Agent and Accept: application/json
- **Timeout**: 30 seconds for all requests
## Error Handling Strategy
### HTTP Status Code Mapping
The system provides user-friendly messages for common HTTP errors:
- `401 Unauthorized` - Invalid API key
- `403 Forbidden` - Permission denied
- `404 Not Found` - Resource doesn't exist
- `422 Unprocessable Entity` - Invalid parameters
- `429 Too Many Requests` - Rate limiting
- `500 Internal Server Error` - Server issues
- `503 Service Unavailable` - Maintenance/downtime
### Error Response Format
All errors return a consistent structure:
```python
{
"error": True,
"status_code": int, # HTTP status code
"message": str # User-friendly error message
}
```
## MCP Tool Implementation Pattern
1. **Parameter Validation**: Check required parameters and provide defaults
2. **API Key Resolution**: Use provided key or fall back to global `API_KEY`
3. **Athlete ID Handling**: Support both numeric and i-prefixed formats
4. **Date Validation**: Parse and validate date strings
5. **API Request**: Call `make_intervals_request()` with appropriate parameters
6. **Error Checking**: Return formatted error messages for API failures
7. **Data Formatting**: Use utilities from [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py)
## Environment Variables
Required configuration (validated on startup):
- `API_KEY` - Cannot be empty
- `ATHLETE_ID` - Must match pattern `r"i?\d+"` (digits or i-prefixed digits)
Optional configuration:
- `INTERVALS_API_BASE_URL` - Defaults to `https://intervals.icu/api/v1`
## Shared HTTP Client
Uses a single `httpx.AsyncClient` instance (`httpx_client`) managed by the FastMCP lifespan context manager to:
- Reuse connections for better performance
- Ensure proper cleanup when server stops
+54
View File
@@ -0,0 +1,54 @@
---
description:
globs:
alwaysApply: true
---
# Development Workflow Guide
## Environment Setup
1. **Use uv for package management**: This project uses [uv](mdc:https:/github.com/astral-sh/uv) instead of pip
```bash
uv venv --python 3.12
source .venv/bin/activate
uv sync --all-extras
```
2. **Environment Configuration**: Copy [.env.example](mdc:.env.example) to `.env` and configure:
- `API_KEY` - Your Intervals.icu API key
- `ATHLETE_ID` - Your athlete ID (digits or i-prefixed)
## Running the Server
- **Manual Testing**: `mcp run src/intervals_mcp_server/server.py`
- **Claude Desktop Integration**: Use `mcp install` command as documented in [README.md](mdc:README.md)
## Code Quality Checks
Before committing, ensure all three checks pass:
1. **Linting**: `ruff .` - Uses default ruff rules, config in [pyproject.toml](mdc:pyproject.toml)
2. **Type Checking**: `mypy src tests` - Static type analysis
3. **Testing**: `pytest` - Unit tests in [tests/](mdc:tests) directory
## Code Organization
- **Main Logic**: All MCP tools are implemented in [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py)
- **Utilities**: Helper functions in [src/intervals_mcp_server/utils/](mdc:src/intervals_mcp_server/utils)
- **API Communication**: `make_intervals_request()` function handles all Intervals.icu API calls
- **Error Handling**: Comprehensive HTTP error handling with user-friendly messages
## Adding New MCP Tools
1. Create async function decorated with `@mcp.tool()`
2. Add proper type hints and docstrings
3. Use `make_intervals_request()` for API calls
4. Add formatting utilities to [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) if needed
5. Write unit tests in [tests/](mdc:tests)
## Commit Guidelines
- Use concise commit messages
- Title PRs as `[intervals-mcp-server] <brief description>`
- Ensure `ruff`, `mypy`, and `pytest` all pass
- Document manual testing steps in PR description
+48
View File
@@ -0,0 +1,48 @@
---
description:
globs:
alwaysApply: true
---
# Intervals.icu MCP Server Project Overview
This is a Model Context Protocol (MCP) server for connecting Claude with the Intervals.icu API. The project enables Claude to retrieve and analyze athlete data including activities, events, workouts, and wellness metrics.
## Project Structure
- **Main Entry Point**: [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py) - Contains the FastMCP server implementation with all MCP tools
- **Configuration**: [pyproject.toml](mdc:pyproject.toml) - Project configuration, dependencies, and build settings
- **Environment Setup**: [.env.example](mdc:.env.example) - Template for environment variables (API_KEY, ATHLETE_ID)
- **Documentation**: [README.md](mdc:README.md) - Comprehensive setup and usage guide
- **Developer Guide**: [AGENTS.md](mdc:AGENTS.md) - Contributor and development instructions
## Core Components
### MCP Tools (in server.py)
- `get_activities` - Retrieve athlete activities with filtering options
- `get_activity_details` - Get detailed information for specific activities
- `get_activity_intervals` - Get detailed interval data for activities
- `get_events` - Retrieve upcoming events (workouts, races, etc.)
- `get_event_by_id` - Get detailed information for specific events
- `get_wellness_data` - Fetch wellness metrics and data
### Utilities
- **Formatting**: [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) - Data formatting utilities for MCP responses
### Testing
- **Tests Directory**: [tests/](mdc:tests) - Unit tests for server functionality and utilities
- **Sample Data**: [tests/sample_data.py](mdc:tests/sample_data.py) - Test data for development
## Key Technologies
- **Python 3.12+** - Required runtime version
- **FastMCP** - MCP server framework
- **httpx** - Async HTTP client for API calls
- **uv** - Package manager and virtual environment tool
- **pytest** - Testing framework
- **ruff** - Linting and code formatting
- **mypy** - Static type checking
## Environment Variables
- `API_KEY` - Intervals.icu API key (required)
- `ATHLETE_ID` - Target athlete ID (required)
- `INTERVALS_API_BASE_URL` - API base URL (optional, defaults to intervals.icu)
- `LOG_LEVEL` - Logging level (optional, defaults to INFO)
+91
View File
@@ -0,0 +1,91 @@
---
description:
globs:
alwaysApply: true
---
# Python Best Practices for Intervals MCP Server
## Code Style and Readability
- Follow **PEP 8** for formatting, indentation, and naming conventions
- Write readable, maintainable code:
- Use descriptive names for variables, functions, and classes
- Keep functions short and single-purpose (see MCP tools in [server.py](mdc:src/intervals_mcp_server/server.py))
- Keep indentation and spacing consistent
- Embrace Pythonic idioms:
- Use list/dict comprehensions and generators where appropriate
- Prefer built-in functions and stdlib modules
- Use context managers (like the `lifespan` manager for httpx client)
- Avoid global variables except for validated constants (API_KEY, ATHLETE_ID)
- Use lazy **`%`** formatting in logging calls: `logger.debug("val=%s", val)`
- Represent datetimes as timezone-aware UTC objects when possible
## Type Annotations and Documentation
- Add **type hints** to all function signatures (see examples in [server.py](mdc:src/intervals_mcp_server/server.py))
- Use built-in collection types (`list`, `dict`, `set`, `tuple`) instead of `typing.List`, etc.
- Provide clear **docstrings** for:
- All MCP tool functions (required by FastMCP)
- Public utility functions in [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py)
- The main module docstring explaining the server's purpose
- Use inline comments only for non-obvious logic
## Error Handling and Validation
- Handle errors gracefully with explicit `try/except` blocks (see `make_intervals_request()`)
- Catch specific exceptions: `httpx.HTTPStatusError`, `httpx.RequestError`, etc.
- Return consistent error structures with user-friendly messages
- Validate inputs:
- Check API key and athlete ID on startup
- Validate date formats in MCP tools
- Use regex pattern `r"i?\d+"` for athlete ID validation
## Async Programming Patterns
- Use `async/await` consistently for all MCP tools and API calls
- Share a single `httpx.AsyncClient` instance across requests
- Properly close async resources using lifespan context manager
- Follow FastMCP's async patterns for tool implementations
## Testing Standards
- Write unit tests for all MCP tools and utilities
- Use **pytest** with `pytest-asyncio` for async function testing
- Use **pytest-mock** (`MockerFixture`) for mocking HTTP requests
- Test both success and error paths
- Mock external API calls to avoid dependencies in tests
## Development Environment
- Target **Python 3.12+** as specified in [pyproject.toml](mdc:pyproject.toml)
- Use **uv** for package management: `uv sync --all-extras`
- Manage dependencies in `pyproject.toml` with lock file (`uv.lock`)
- Always use virtual environments (`.venv/`)
- Run quality checks before commits:
- `ruff .` for linting
- `mypy src tests` for type checking
- `pytest` for tests
## Security Practices
- Never hard-code secrets - use environment variables via `.env` file
- Load sensitive data (API_KEY, ATHLETE_ID) from environment
- Use HTTP Basic Auth for API authentication
- Validate all external inputs before processing
- Follow least-privilege principles for API access
## Project-Specific Patterns
- All API communication through `make_intervals_request()` function
- Consistent error response format: `{"error": True, "status_code": int, "message": str}`
- Use formatting utilities from [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py)
- Follow MCP tool naming conventions: `get_*` for retrieval operations
- Support both numeric and i-prefixed athlete IDs
## Code Organization
- Keep all MCP tools in [server.py](mdc:src/intervals_mcp_server/server.py)
- Place formatting utilities in [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py)
- Organize tests by functionality in [tests/](mdc:tests) directory
- Use `__init__.py` files to mark Python packages
- Include `py.typed` for type checking support
+89
View File
@@ -0,0 +1,89 @@
---
description:
globs:
alwaysApply: true
---
# Testing Patterns and Practices
## Test Organization
Tests are organized in the [tests/](mdc:tests) directory with the following structure:
- **[tests/test_server.py](mdc:tests/test_server.py)** - Main MCP tool testing
- **[tests/test_formatting.py](mdc:tests/test_formatting.py)** - Utility function tests
- **[tests/test_make_intervals_request.py](mdc:tests/test_make_intervals_request.py)** - API communication tests
- **[tests/sample_data.py](mdc:tests/sample_data.py)** - Mock data for testing
## Testing Framework Setup
- **Framework**: pytest with async support (`pytest-asyncio`)
- **Mocking**: pytest-mock for HTTP request mocking
- **Configuration**: Test settings in [pyproject.toml](mdc:pyproject.toml) under `[tool.pytest.ini_options]`
## Testing Patterns
### Async Testing
All MCP tools are async functions, so tests use:
```python
@pytest.mark.asyncio
async def test_function_name():
# Test async MCP tools
```
### Mock API Responses
HTTP requests are mocked using `pytest-mock`:
```python
def test_api_call(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {"test": "data"}
mock_response.raise_for_status.return_value = None
mocker.patch("httpx.AsyncClient.get", return_value=mock_response)
```
### Test Data Management
- **Sample Data**: [tests/sample_data.py](mdc:tests/sample_data.py) contains realistic mock data
- **Isolation**: Each test uses fresh mock data to avoid side effects
- **Coverage**: Tests cover both success and error scenarios
## Running Tests
```bash
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run specific test file
pytest tests/test_server.py
# Run with coverage (if installed)
pytest --cov=src/intervals_mcp_server
```
## Test Requirements
Before committing code, ensure:
1. **All tests pass**: `pytest` returns exit code 0
2. **No new linting errors**: `ruff .` passes
3. **Type checking passes**: `mypy src tests` succeeds
## Writing New Tests
When adding new MCP tools or utilities:
1. **Create test cases** in appropriate test file
2. **Mock external dependencies** (HTTP requests, file I/O)
3. **Test both success and error paths**
4. **Use realistic test data** from [tests/sample_data.py](mdc:tests/sample_data.py)
5. **Follow async testing patterns** for MCP tools
6. **Verify error message formatting** for user-facing errors
## Mock Strategy
The project uses comprehensive mocking to:
- **Avoid real API calls** during testing
- **Test error handling** by simulating various HTTP error responses
- **Ensure deterministic results** with controlled test data
- **Speed up test execution** by eliminating network requests
+13
View File
@@ -0,0 +1,13 @@
# Intervals.icu API Configuration
# Base URL (Optional, defaults to https://intervals.icu/api/v1)
# INTERVALS_API_BASE_URL=https://intervals.icu/api/v1
# Required: Your Intervals.icu API Key
API_KEY=your_intervals_api_key_here
# Required: Your Intervals.icu Athlete ID
ATHLETE_ID=your_athlete_id_here
# The API_KEY and ATHLETE_ID will be used as defaults for all API calls
# when these parameters are not explicitly provided to the tool functions.
+31
View File
@@ -0,0 +1,31 @@
name: build-image
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE: git.farh.net/farhoodlabs/intervalsicu-mcp
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push image
run: |
set -eu
echo "${{ secrets.GITEA_TOKEN }}" | docker login git.farh.net -u "${{ github.actor }}" --password-stdin
docker build \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${GITHUB_SHA}" \
.
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${GITHUB_SHA}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}"
+185
View File
@@ -0,0 +1,185 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# VSCode
.vscode
# TODO
TODO
.gitconfig
.claude/
.DS_Store
CLAUDE.md
+56
View File
@@ -0,0 +1,56 @@
# Quick Start:
#
# pip install pre-commit
# pre-commit install && pre-commit install -t pre-push
# pre-commit run --all-files
#
# To Skip Checks:
#
# git commit --no-verify
# git push --no-verify
# test
#
# To update all hooks automatically:
#
# pre-commit autoupdate
fail_fast: false
default_language_version:
python: python3.12
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: no-commit-to-branch # prevent direct commits to the `main` branch
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
# ruff
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.11
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
# typos
- repo: https://github.com/crate-ci/typos
rev: v1.32.0
hooks:
- id: typos
# pytest
- repo: local
hooks:
- id: tests
name: run tests
entry: pytest -v tests
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
+23
View File
@@ -0,0 +1,23 @@
# Contributor Guide
This project is a Python 3.12 backend service built with FastMCP and httpx. All source code lives under `src/intervals_mcp_server` and tests live under `tests`.
## Development Environment
- Use [uv](https://github.com/astral-sh/uv) to create and manage the virtual environment.
- `uv venv --python 3.12`
- `source .venv/bin/activate`
- Sync dependencies including dev extras with `uv sync --all-extras`.
- When editing or running the server manually use `mcp run src/intervals_mcp_server/server.py`.
## Testing Instructions
- Run unit tests with `pytest` from the repository root.
- Ensure linting passes with `ruff .` (no configuration file means default rules).
- Run static type checks using `mypy src tests`.
- All three steps (`ruff`, `mypy`, and `pytest`) should succeed before committing.
## PR Instructions
- Use concise commit messages.
- Title pull requests using the format `[intervals-mcp-server] <brief description>`.
- Describe any manual testing steps performed and mention whether `pytest`, `ruff`, and `mypy` passed.
There is currently no frontend code in this repository. If a frontend is added in the future (for example with React or another framework), document how to run and test it within this file.
+63
View File
@@ -0,0 +1,63 @@
# Contributing to Intervals.icu MCP Server
Thank you for taking the time to contribute! This project uses **Python 3.12** and manages its dependencies with [uv](https://github.com/astral-sh/uv). The following guide summarizes how to set up your environment and outlines the workflow we expect for pull requests.
## Development environment
1. Create a virtual environment and activate it:
```bash
uv venv --python 3.12
source .venv/bin/activate
```
2. Install all dependencies (including development extras):
```bash
uv sync --all-extras
```
3. When working on or manually running the server, use:
```bash
mcp run src/intervals_mcp_server/server.py
```
## Dependency changes
1. Edit `pyproject.toml`.
2. Run `uv lock` (or `uv sync`).
3. Commit **both** `pyproject.toml` and `uv.lock` in the same commit.
If you add, remove, or relax a dependency but forget to update the lock file, CI will fail. Treat `uv.lock` as a first-class artifact: review it when it changes, but dont fear committing it.
## Code-only changes
For changes that do not modify dependencies, keep the lock file untouched. Run your tests with:
```bash
uv run --locked pytest
```
CI will also run `uv lock --check` to ensure `uv.lock` stays in sync.
## Why keep the lock file?
* **Reproducibility** All collaborators and CI runners install identical hashes.
* **Security** Hash pinning in `uv.lock` helps prevent supply-chain attacks.
* **Speed** `uv` skips resolution when the lock matches, keeping installs lightning-fast.
Automated dependency upgrades are encouraged. You can use Dependabot, Renovate, or a scheduled GitHub Action that runs `uv lock --upgrade && git push` to keep the file fresh and generate tidy PRs.
## Testing
Before opening a pull request, ensure all checks pass locally:
```bash
ruff check .
mypy src tests
uv run --locked pytest
```
## Pull request guidelines
* Use concise commit messages.
* Title your pull request using the format `[intervals-mcp-server] <brief description>`.
* Describe any manual testing you performed and confirm whether `ruff`, `mypy`, and `pytest` passed.
We appreciate your contributions and your attention to these guidelines. Happy coding!
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# Install build dependencies and Python build backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python build tool
RUN pip install --no-cache-dir hatchling
# Copy project files
COPY pyproject.toml pyproject.toml
COPY src src
COPY README.md README.md
COPY .env.example .env.example
# Install the package and runtime dependencies
RUN pip install --no-cache-dir .
# Run the MCP server. Transport is selected via MCP_TRANSPORT (default stdio);
# the deployment sets MCP_TRANSPORT=http for streamable-HTTP. Auth + stateless
# JSON transport are configured in code (see mcp_instance.py), no monkeypatch.
CMD ["python", "-m", "intervals_mcp_server.server"]
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+4
View File
@@ -0,0 +1,4 @@
include README.md
include .env.example
include server.py
include utils/*.py
+379
View File
@@ -0,0 +1,379 @@
# Intervals.icu MCP Server
Model Context Protocol (MCP) server for connecting Claude and ChatGPT with the Intervals.icu API. It provides tools for authentication and data retrieval for activities, events, wellness data, power curves, and custom items.
If you find the Model Context Protocol (MCP) server useful, please consider supporting its continued development with a donation.
## Requirements
- Python 3.12 or higher
- [Model Context Protocol (MCP) Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- httpx
- python-dotenv
## Setup
### 1. Install uv (recommended)
**macOS/Linux:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Windows (PowerShell):**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
After installation, find the full path to `uv` — you'll need it later when configuring Claude Desktop:
```powershell
where.exe uv
# Example output: C:\Users\<USERNAME>\.local\bin\uv.exe
```
### 2. Clone this repository
```bash
git clone https://github.com/mvilanova/intervals-mcp-server.git
cd intervals-mcp-server
```
### 3. Create and activate a virtual environment
```bash
# Create virtual environment with Python 3.12
uv venv --python 3.12
# Activate virtual environment
# On macOS/Linux:
source .venv/bin/activate
# On Windows:
.venv\Scripts\activate
```
### 4. Sync project dependencies
```bash
uv sync
```
### 5. Set up environment variables
Make a copy of `.env.example` and name it `.env` by running the following command:
**macOS/Linux:**
```bash
cp .env.example .env
```
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
```
Then edit the `.env` file and set your Intervals.icu athlete id and API key:
```
API_KEY=your_intervals_api_key_here
ATHLETE_ID=your_athlete_id_here
```
#### Getting your Intervals.icu API Key
1. Log in to your Intervals.icu account
2. Go to Settings > API
3. Generate a new API key
#### Finding your Athlete ID
Your athlete ID is typically visible in the URL when you're logged into Intervals.icu. It looks like:
- `https://intervals.icu/athlete/i12345/...` where `i12345` is your athlete ID
## Updating
This project is actively developed, with new features and fixes added regularly. To stay up to date, follow these steps:
### 1. Pull the latest changes from `main`
> ⚠️ Make sure you don't have uncommitted changes before running this command.
**macOS/Linux:**
```bash
git checkout main && git pull
```
**Windows (PowerShell):**
```powershell
git checkout main; git pull
```
### 2. Update Python dependencies
Activate your virtual environment and sync dependencies:
**macOS/Linux:**
```bash
source .venv/bin/activate
uv sync
```
**Windows (PowerShell):**
```powershell
.venv\Scripts\activate
uv sync
```
### Troubleshooting
If Claude Desktop fails due to configuration changes, follow these steps:
1. Delete the existing `Intervals.icu` entry in `claude_desktop_config.json`.
2. Reconfigure Claude Desktop from the `intervals-mcp-server` directory.
**macOS/Linux:**
```bash
mcp install src/intervals_mcp_server/server.py --name "Intervals.icu" --with-editable . --env-file .env
```
**Windows:** Re-add the entry manually as described in the [Windows configuration section](#windows).
#### Common errors
**`spawn uv ENOENT`** — Claude Desktop cannot find the `uv` executable. Use the full path to `uv` in the `command` field. Run `which uv` (macOS/Linux) or `where.exe uv` (Windows) to get it.
**`spawn /Users/... ENOENT` on Windows** — The config file contains a macOS/Linux-style path. Replace it with the correct Windows path using backslashes as described in the [Windows configuration section](#windows) below.
**Windows Store install: config changes not taking effect** — You may be editing the wrong config file. Claude Desktop installed from the Microsoft Store reads from `AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`, not `AppData\Roaming\Claude\`.
## Usage with Claude
### 1. Configure Claude Desktop
To use this server with Claude Desktop, you need to add it to your Claude Desktop configuration.
#### macOS/Linux
1. Run the following from the `intervals-mcp-server` directory to configure Claude Desktop:
```bash
mcp install src/intervals_mcp_server/server.py --name "Intervals.icu" --with-editable . --env-file .env
```
2. If you open your Claude Desktop App configuration file `claude_desktop_config.json`, it should look like this:
```json
{
"mcpServers": {
"Intervals.icu": {
"command": "/Users/<USERNAME>/.local/bin/uv",
"args": [
"run",
"--with",
"mcp[cli]",
"--with-editable",
"/path/to/intervals-mcp-server",
"mcp",
"run",
"/path/to/intervals-mcp-server/src/intervals_mcp_server/server.py"
],
"env": {
"INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1",
"ATHLETE_ID": "<YOUR_ATHLETE_ID>",
"API_KEY": "<YOUR_API_KEY>",
"LOG_LEVEL": "INFO"
}
}
}
}
```
Where `/path/to/` is the path to the `intervals-mcp-server` code folder in your system.
#### Windows
The `mcp install` command may fail on Windows due to environment or permission issues. Instead, configure Claude Desktop manually:
1. Find the Claude Desktop config file. If Claude Desktop was installed from the **Microsoft Store**, the config is located at:
```
C:\Users\<USERNAME>\AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json
```
If installed via the standard installer, it may be at:
```
C:\Users\<USERNAME>\AppData\Roaming\Claude\claude_desktop_config.json
```
If the file or folder does not exist, create it.
2. Add the following entry to `claude_desktop_config.json`, replacing the placeholders with your actual values:
```json
{
"mcpServers": {
"Intervals.icu": {
"command": "C:\\Users\\<USERNAME>\\.local\\bin\\uv.exe",
"args": [
"run",
"--with",
"mcp[cli]",
"--with-editable",
"C:\\path\\to\\intervals-mcp-server",
"mcp",
"run",
"C:\\path\\to\\intervals-mcp-server\\src\\intervals_mcp_server\\server.py"
],
"env": {
"INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1",
"ATHLETE_ID": "<YOUR_ATHLETE_ID>",
"API_KEY": "<YOUR_API_KEY>",
"LOG_LEVEL": "INFO"
}
}
}
}
```
- Use double backslashes (`\\`) for all Windows paths in JSON.
- To find the full path to `uv.exe`, run `where.exe uv` in PowerShell.
- To find the full path to the cloned repository, run `pwd` from inside the `intervals-mcp-server` folder.
> **Note for Windows Store installs:** Claude Desktop installed from the Microsoft Store sandboxes its config under `AppData\Local\Packages\...`. Editing `AppData\Roaming\Claude\claude_desktop_config.json` will have no effect — make sure you edit the correct file.
3. Restart Claude Desktop.
### 2. Use the MCP server with Claude
Once the server is running and Claude Desktop is configured, you can use the following tools to ask questions about your past and future activities, events, and wellness data.
- `get_activities`: Retrieve a list of activities
- `get_activity_details`: Get detailed information for a specific activity
- `get_activity_intervals`: Get detailed interval data for a specific activity
- `get_activity_streams`: Get raw data streams (power, heart rate, etc.) for a specific activity
- `get_athlete_power_curves`: Get best power output curves for selected durations and time periods
- `get_wellness_data`: Fetch wellness data
- `get_events`: Retrieve upcoming events (workouts, races, etc.)
- `get_event_by_id`: Get detailed information for a specific event
- `add_or_update_event`: Create or update an event (workout, race, note, etc.)
- `delete_event`: Delete a specific event
- `delete_events_by_date_range`: Delete events within a date range
- `get_custom_items`: Get custom items (charts, custom fields, zones, etc.) for an athlete
- `get_custom_item_by_id`: Get detailed information for a specific custom item
- `create_custom_item`: Create a new custom item for an athlete
- `update_custom_item`: Update an existing custom item
- `delete_custom_item`: Delete a custom item
## Usage with ChatGPT
ChatGPTs beta MCP connectors can also talk to this server over the SSE transport.
1. Start the server in SSE mode so it exposes the `/sse` and `/messages/` endpoints:
```bash
export FASTMCP_HOST=127.0.0.1 FASTMCP_PORT=8765 MCP_TRANSPORT=sse FASTMCP_LOG_LEVEL=INFO
python src/intervals_mcp_server/server.py
```
The startup log prints the full URLs (for example `http://127.0.0.1:8765/sse`). ChatGPT needs that public URL, so forward the port with a tool such as `ngrok http 8765` if you are not exposing the server directly.
2. In ChatGPT, open **Settings → Features → Custom MCP Connectors** and click **Add**. Fill in:
- **Name**: `Intervals.icu`
- **MCP Server URL**: `https://<your-public-host>/sse`
- **Authentication**: leave as _No authentication_ unless you have protected your tunnel.
You can reuse the same `ngrok http 8765` tunnel URL here; just ensure it forwards to the host/port you exported above.
3. Save the connector and open a new chat. ChatGPT will keep the SSE connection open and POST follow-up requests to the `/messages/` endpoint announced by the server. If you restart the MCP server or tunnel, rerun the SSE command and update the connector URL if it changes.
## Development and testing
Install development dependencies and run the test suite with:
```bash
uv sync --all-extras
pytest -v tests
```
### Running the server locally
To start the server manually (useful when developing or testing), run:
```bash
mcp run src/intervals_mcp_server/server.py
```
#### Enabling debug logging
To capture server logs for debugging, wrap the command in a shell and redirect stderr to a file.
**macOS/Linux** — modify your `claude_desktop_config.json` like this:
```json
{
"mcpServers": {
"Intervals.icu": {
"command": "/bin/bash",
"args": [
"-c",
"/Users/<USERNAME>/.local/bin/uv run --with 'mcp[cli]' --with-editable /path/to/intervals-mcp-server mcp run /path/to/intervals-mcp-server/src/intervals_mcp_server/server.py 2>> /path/to/intervals-mcp-server/mcp-server.log"
],
"env": {
"INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1",
"ATHLETE_ID": "<YOUR_ATHLETE_ID>",
"API_KEY": "<YOUR_API_KEY>",
"LOG_LEVEL": "INFO"
}
}
}
}
```
Then tail the log file to see output in real-time:
```bash
tail -f /path/to/intervals-mcp-server/mcp-server.log
```
**Windows** — modify your `claude_desktop_config.json` like this:
```json
{
"mcpServers": {
"Intervals.icu": {
"command": "powershell",
"args": [
"-Command",
"C:\\Users\\<USERNAME>\\.local\\bin\\uv.exe run --with 'mcp[cli]' --with-editable C:\\path\\to\\intervals-mcp-server mcp run C:\\path\\to\\intervals-mcp-server\\src\\intervals_mcp_server\\server.py 2>> C:\\path\\to\\intervals-mcp-server\\mcp-server.log"
],
"env": {
"INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1",
"ATHLETE_ID": "<YOUR_ATHLETE_ID>",
"API_KEY": "<YOUR_API_KEY>",
"LOG_LEVEL": "INFO"
}
}
}
}
```
Then monitor the log file in real-time using PowerShell:
```powershell
Get-Content C:\path\to\intervals-mcp-server\mcp-server.log -Wait
```
## License
The GNU General Public License v3.0
## Featured
### Glama.ai
<a href="https://glama.ai/mcp/servers/@mvilanova/intervals-mcp-server">
<img width="380" height="200" src="https://glama.ai/mcp/servers/@mvilanova/intervals-mcp-server/badge" alt="Intervals.icu Server MCP server" />
</a>
+123
View File
@@ -0,0 +1,123 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "intervalsicu-mcp"
version = "0.1.0"
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12"
license = { text = "GPL-3.0-only" }
authors = [{ name = "Marc Vilanova", email = "barker-riddle.8z@icloud.com" }]
dependencies = [
"mcp[cli]>=1.28.1",
"httpx>=0.25.0",
"python-dotenv>=1.0.0",
"pyjwt[crypto]>=2.8.0",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries",
]
keywords = ["intervals", "cycling", "running", "mcp", "ai"]
[project.urls]
"Homepage" = "https://git.farh.net/farhoodlabs/intervalsicu-mcp"
"Bug Tracker" = "https://git.farh.net/farhoodlabs/intervalsicu-mcp/issues"
"Upstream" = "https://github.com/mvilanova/intervals-mcp-server"
[project.optional-dependencies]
dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0"]
[tool.hatch.build]
include = ["server.py", "utils/*.py", "README.md", ".env.example"]
[tool.hatch.build.targets.wheel]
packages = ["src/intervals_mcp_server"]
[tool.hatch.envs.default.scripts]
test = "pytest -q"
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".hg",
".mypy_cache",
".nox",
".pants.d",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
# Select specific errors and warnings
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
# "I", # isort
"C", # flake8-comprehensions
"B", # flake8-bugbear
]
# Ignore specific errors and warnings
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # complexity
"G004", # Ignore f-string in logging
]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["A", "B", "C", "D", "E", "F"]
# No unfixable rules.
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
isort.known-third-party = ["starlette"]
# Unlike Flake8, default to a complexity level of 10.
mccabe.max-complexity = 10
[tool.pylint.design]
max-args = 8
max-positional-arguments = 8
[tool.typos]
default.check-filename = true
default.check-file = true
default.unicode = true
default.locale = "en-us"
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
python_files = "test_*.py"
asyncio_default_fixture_loop_scope = "function"
[tool.uv]
index-url = "https://pypi.org/simple"
+5
View File
@@ -0,0 +1,5 @@
"""
API client module for Intervals.icu MCP Server.
This module contains the HTTP client and API request handling logic.
"""
+242
View File
@@ -0,0 +1,242 @@
"""
API client for Intervals.icu MCP Server.
This module handles all HTTP communication with the Intervals.icu API,
including request management, error handling, and client lifecycle.
"""
from json import JSONDecodeError
import json
import logging
import sys
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Any
import httpx # pylint: disable=import-error
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.config import get_config
logger = logging.getLogger("intervals_icu_mcp_server")
# Create a single AsyncClient instance for all requests (lazily initialized)
# This can be monkeypatched via server.httpx_client for testing
httpx_client: httpx.AsyncClient | None = None # pylint: disable=invalid-name
async def _get_httpx_client() -> httpx.AsyncClient:
"""
Lazily create or reuse the shared httpx AsyncClient.
The client may be closed by downstream transports between tool invocations,
so we recreate it when necessary.
This function checks server.httpx_client first (if available) to support
test monkeypatching via server.httpx_client.
"""
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
# Check for monkeypatched client in server module first (for test compatibility)
# This allows tests to monkeypatch server.httpx_client and have it work
try:
server_module = sys.modules.get("intervals_mcp_server.server")
if server_module and hasattr(server_module, "httpx_client"):
server_client = server_module.httpx_client
if server_client is not None and not server_client.is_closed:
return server_client
except (AttributeError, ImportError):
pass
# Use this module's httpx_client
if httpx_client is None or httpx_client.is_closed:
httpx_client = httpx.AsyncClient()
return httpx_client
@asynccontextmanager
async def setup_api_client(_app: FastMCP):
"""
Context manager to ensure the shared httpx client is closed when the server stops.
Args:
_app (FastMCP): The MCP server application instance.
"""
try:
yield
finally:
# Close the module-level httpx_client
if httpx_client and not httpx_client.is_closed:
await httpx_client.aclose()
# Also close server.httpx_client if it exists (for test compatibility)
# This ensures monkeypatched clients in tests are properly closed
try:
server_module = sys.modules.get("intervals_mcp_server.server")
if server_module and hasattr(server_module, "httpx_client"):
server_client = getattr(server_module, "httpx_client", None)
if server_client is not None and not server_client.is_closed:
await server_client.aclose()
except (AttributeError, ImportError):
pass
def _get_error_message(error_code: int, error_text: str) -> str:
"""Return a user-friendly error message for a given HTTP status code."""
error_messages = {
HTTPStatus.UNAUTHORIZED: f"{HTTPStatus.UNAUTHORIZED.value} {HTTPStatus.UNAUTHORIZED.phrase}: Please check your API key.",
HTTPStatus.FORBIDDEN: f"{HTTPStatus.FORBIDDEN.value} {HTTPStatus.FORBIDDEN.phrase}: You may not have permission to access this resource.",
HTTPStatus.NOT_FOUND: f"{HTTPStatus.NOT_FOUND.value} {HTTPStatus.NOT_FOUND.phrase}: The requested endpoint or ID doesn't exist.",
HTTPStatus.UNPROCESSABLE_ENTITY: f"{HTTPStatus.UNPROCESSABLE_ENTITY.value} {HTTPStatus.UNPROCESSABLE_ENTITY.phrase}: The server couldn't process the request (invalid parameters or unsupported operation).",
HTTPStatus.TOO_MANY_REQUESTS: f"{HTTPStatus.TOO_MANY_REQUESTS.value} {HTTPStatus.TOO_MANY_REQUESTS.phrase}: Too many requests in a short time period.",
HTTPStatus.INTERNAL_SERVER_ERROR: f"{HTTPStatus.INTERNAL_SERVER_ERROR.value} {HTTPStatus.INTERNAL_SERVER_ERROR.phrase}: The Intervals.icu server encountered an internal error.",
HTTPStatus.SERVICE_UNAVAILABLE: f"{HTTPStatus.SERVICE_UNAVAILABLE.value} {HTTPStatus.SERVICE_UNAVAILABLE.phrase}: The Intervals.icu server might be down or undergoing maintenance.",
}
try:
status = HTTPStatus(error_code)
return error_messages.get(status, error_text)
except ValueError:
return error_text
def _prepare_request_config(
url: str,
api_key: str | None,
method: str,
) -> tuple[str, httpx.BasicAuth, dict[str, str], str | None]:
"""Prepare request configuration including headers, auth, and URL.
Returns:
Tuple of (full_url, auth, headers, error_message).
error_message is None if configuration is valid.
"""
config = get_config()
headers = {"User-Agent": config.user_agent, "Accept": "application/json"}
if method in ["POST", "PUT"]:
headers["Content-Type"] = "application/json"
# Use provided api_key or fall back to global API_KEY
key_to_use = api_key if api_key is not None else config.api_key
if not key_to_use:
logger.error("No API key provided for request to: %s", url)
return (
"",
httpx.BasicAuth("", ""),
{},
"API key is required. Set API_KEY env var or pass api_key",
)
auth = httpx.BasicAuth("API_KEY", key_to_use)
full_url = f"{config.intervals_api_base_url}{url}"
return full_url, auth, headers, None
def _parse_response(
response: httpx.Response, full_url: str
) -> dict[str, Any] | list[dict[str, Any]]:
"""Parse HTTP response and return JSON data or error dict.
Returns:
Parsed JSON response or error dict.
"""
try:
response_data = response.json() if response.content else {}
except JSONDecodeError:
logger.error("Invalid JSON in response from: %s", full_url)
return {"error": True, "message": "Invalid JSON in response"}
response.raise_for_status()
return response_data
async def make_intervals_request(
url: str,
api_key: str | None = None,
params: dict[str, Any] | None = None,
method: str = "GET",
data: dict[str, Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
"""
Make a request to the Intervals.icu API with proper error handling.
Args:
url (str): The API endpoint path (e.g., '/athlete/{id}/activities').
api_key (str | None): Optional API key to use for authentication. Defaults to the global API_KEY.
params (dict[str, Any] | None): Optional query parameters for the request.
method (str): HTTP method to use (GET, POST, etc.). Defaults to GET.
data (dict[str, Any] | None): Optional data to send in the request body.
Returns:
dict[str, Any] | list[dict[str, Any]]: The parsed JSON response from the API, or an error dict.
"""
# Prepare request configuration
full_url, auth, headers, error_msg = _prepare_request_config(url, api_key, method)
if error_msg:
return {"error": True, "message": error_msg}
async def _send_request(client: httpx.AsyncClient) -> httpx.Response:
if method in {"POST", "PUT"} and data is not None:
body = json.dumps(data)
logger.debug("Request %s %s body: %s", method, full_url, body)
return await client.request(
method=method,
url=full_url,
headers=headers,
params=params,
auth=auth,
timeout=30.0,
content=body,
)
return await client.request(
method=method,
url=full_url,
headers=headers,
params=params,
auth=auth,
timeout=30.0,
)
try:
client = await _get_httpx_client()
try:
response = await _send_request(client)
except RuntimeError as runtime_error:
# httpx closes the client when the underlying connection is severed;
# recreate the shared client lazily and retry once.
if "client has been closed" not in str(runtime_error).lower():
raise
logger.warning("HTTPX client was closed; creating a new instance for retries.")
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
httpx_client = None
client = await _get_httpx_client()
response = await _send_request(client)
return _parse_response(response, full_url)
except httpx.HTTPStatusError as e:
return _handle_http_status_error(e)
except httpx.RequestError as e:
logger.error("Request error: %s", str(e))
return {"error": True, "message": f"Request error: {str(e)}"}
except httpx.HTTPError as e:
logger.error("HTTP client error: %s", str(e))
return {"error": True, "message": f"HTTP client error: {str(e)}"}
def _handle_http_status_error(e: httpx.HTTPStatusError) -> dict[str, Any]:
"""Handle HTTP status errors and return formatted error dict.
Args:
e: The HTTPStatusError exception.
Returns:
Error dictionary with status code and message.
"""
error_code = e.response.status_code
error_text = e.response.text
logger.error("HTTP error: %s - %s", error_code, error_text)
return {
"error": True,
"status_code": error_code,
"message": _get_error_message(error_code, error_text),
}
+101
View File
@@ -0,0 +1,101 @@
"""
Native OAuth token verification for the Intervals.icu MCP Server.
This replaces the previous runtime monkeypatch: authentication is configured
here, in code, and enabled automatically when the OAuth environment variables
(``MCP_ISSUER`` / ``MCP_RESOURCE`` / ``MCP_JWKS_URI``) are present — i.e. for
the HTTP transport running behind an OAuth authorization server (Authentik).
When those variables are absent (e.g. stdio / local development / tests) auth
is disabled and the server runs unauthenticated.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger("intervals_icu_mcp_server")
class AuthentikTokenVerifier:
"""Verify RS256 Bearer JWTs against a JWKS endpoint (RFC 9068 style)."""
def __init__(self, jwks_uri: str, issuer: str, audience: list[str]):
import jwt # PyJWT
self._jwks = jwt.PyJWKClient(jwks_uri)
self._issuer = issuer
self._audience = audience
async def verify_token(self, token: str):
"""Return an AccessToken if the JWT is valid, else None (unauthenticated)."""
import jwt
from mcp.server.auth.provider import AccessToken
try:
key = self._jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
issuer=self._issuer,
audience=self._audience,
options={"require": ["exp", "iat", "iss", "aud"]},
)
except Exception as exc: # noqa: BLE001 - any failure means unauthenticated
logger.debug("Token verification failed: %s", exc)
return None
aud = claims.get("aud")
resource = aud[0] if isinstance(aud, list) else aud
return AccessToken(
token=token,
client_id=claims.get("azp") or resource,
scopes=(claims.get("scope") or "").split(),
expires_at=claims.get("exp"),
resource=resource,
subject=claims.get("sub"),
claims=claims,
)
def _audience_variants(resource: str, client_id: str | None) -> list[str]:
"""Accepted token audiences.
RFC 8707 clients use the ``resource`` value advertised in the protected
resource metadata, which pydantic's ``AnyHttpUrl`` normalises *with* a
trailing slash. The raw env var is typically supplied *without* one, so we
accept both forms (plus the OAuth client_id) to avoid audience mismatches.
"""
base = resource.rstrip("/")
values = [base, base + "/"]
if client_id:
values.append(client_id)
# de-duplicate while preserving order
seen: dict[str, None] = {}
for value in values:
seen.setdefault(value, None)
return list(seen.keys())
def build_auth():
"""Return ``(AuthSettings, TokenVerifier)`` when OAuth is configured, else ``(None, None)``."""
issuer = os.getenv("MCP_ISSUER")
resource = os.getenv("MCP_RESOURCE")
jwks_uri = os.getenv("MCP_JWKS_URI")
client_id = os.getenv("MCP_CLIENT_ID")
if not (issuer and resource and jwks_uri):
return None, None
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl
settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(resource),
)
verifier = AuthentikTokenVerifier(jwks_uri, issuer, _audience_variants(resource, client_id))
logger.info("Native OAuth enabled (issuer=%s, resource=%s)", issuer, resource)
return settings, verifier
+72
View File
@@ -0,0 +1,72 @@
"""
Configuration management for Intervals.icu MCP Server.
This module handles loading and validation of configuration from environment variables.
"""
import os
from dataclasses import dataclass
from intervals_mcp_server.utils.validation import validate_athlete_id
# Try to load environment variables from .env file if it exists
try:
from dotenv import load_dotenv
_ = load_dotenv()
except ImportError:
# python-dotenv not installed, proceed without it
pass
@dataclass
class Config:
"""Configuration settings for the Intervals.icu MCP Server."""
api_key: str
athlete_id: str
intervals_api_base_url: str
user_agent: str
_config_instance: Config | None = None # pylint: disable=invalid-name
def load_config() -> Config:
"""
Load configuration from environment variables.
Returns:
Config: Configuration instance with loaded values.
Raises:
ValueError: If athlete_id is invalid (when non-empty).
"""
api_key = os.getenv("API_KEY", "")
athlete_id = os.getenv("ATHLETE_ID", "")
intervals_api_base_url = os.getenv("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1")
user_agent = "intervalsicu-mcp-server/1.0"
# Validate athlete_id if provided (empty string is allowed)
if athlete_id:
validate_athlete_id(athlete_id)
return Config(
api_key=api_key,
athlete_id=athlete_id,
intervals_api_base_url=intervals_api_base_url,
user_agent=user_agent,
)
def get_config() -> Config:
"""
Get the configuration instance (singleton pattern).
Returns:
Config: The configuration instance.
"""
global _config_instance # pylint: disable=global-statement # noqa: PLW0603 - singleton pattern
if _config_instance is None:
_config_instance = load_config()
return _config_instance
+43
View File
@@ -0,0 +1,43 @@
"""
Shared MCP instance module.
Provides a shared FastMCP instance imported by both the server module and the
tool modules (avoiding cyclic imports). Transport and authentication are
configured here from the environment — there is no runtime monkeypatching:
- HTTP transport (``MCP_TRANSPORT=http``/``streamable-http``) is served
statelessly with plain JSON responses (robust behind proxies / MCP
connectors, which handle a single JSON body far better than a large SSE
stream).
- Native OAuth (Authentik) is enabled when ``MCP_ISSUER`` / ``MCP_RESOURCE`` /
``MCP_JWKS_URI`` are set. See :mod:`intervals_mcp_server.auth`.
"""
from __future__ import annotations
import os
from typing import Any
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.api.client import setup_api_client
from intervals_mcp_server.auth import build_auth
_kwargs: dict[str, Any] = {"lifespan": setup_api_client}
# HTTP transport tuning: stateless + single JSON response body.
if os.getenv("MCP_TRANSPORT", "stdio").lower() in ("http", "streamable-http"):
_kwargs["stateless_http"] = True
_kwargs["json_response"] = True
if os.getenv("FASTMCP_HOST"):
_kwargs["host"] = os.environ["FASTMCP_HOST"]
if os.getenv("FASTMCP_PORT"):
_kwargs["port"] = int(os.environ["FASTMCP_PORT"])
# Native OAuth (Authentik) when configured via environment.
_auth_settings, _token_verifier = build_auth()
if _auth_settings is not None and _token_verifier is not None:
_kwargs["auth"] = _auth_settings
_kwargs["token_verifier"] = _token_verifier
mcp: FastMCP = FastMCP("intervals-icu", **_kwargs) # pylint: disable=invalid-name
View File
+132
View File
@@ -0,0 +1,132 @@
"""
Intervals.icu MCP Server
This module implements a Model Context Protocol (MCP) server for connecting
Claude with the Intervals.icu API. It provides tools for retrieving and managing
athlete data, including activities, events, workouts, and wellness metrics.
Main Features:
- Activity retrieval and detailed analysis
- Event management (races, workouts, calendar items)
- Wellness data tracking and visualization
- Error handling with user-friendly messages
- Configurable parameters with environment variable support
Usage:
This server is designed to be run as a standalone script and exposes several MCP tools
for use with Claude Desktop or other MCP-compatible clients. The server loads configuration
from environment variables (optionally via a .env file) and communicates with the Intervals.icu API.
To run the server:
$ python src/intervals_mcp_server/server.py
MCP tools provided:
- get_activities
- get_activity_details
- get_activity_intervals
- get_activity_streams
- get_activity_messages
- add_activity_message
- get_events
- get_event_by_id
- add_or_update_event
- delete_event
- delete_events_by_date_range
- get_wellness_data
- get_athlete_power_curves
- get_custom_items
- get_custom_item_by_id
- create_custom_item
- update_custom_item
- delete_custom_item
See the README for more details on configuration and usage.
"""
import logging
# Import API client and configuration
from intervals_mcp_server.api.client import (
httpx_client, # Re-export for backward compatibility with tests
make_intervals_request,
)
from intervals_mcp_server.config import get_config
from intervals_mcp_server.mcp_instance import mcp
# Import types and validation
from intervals_mcp_server.server_setup import setup_transport, start_server
from intervals_mcp_server.utils.validation import validate_athlete_id
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("intervals_icu_mcp_server")
# Get configuration instance
config = get_config()
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
# Import tool functions for re-export
from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402
add_activity_message,
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_messages,
get_activity_streams,
)
from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-position # noqa: E402
add_or_update_event,
delete_event,
delete_events_by_date_range,
get_event_by_id,
get_events,
)
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item,
delete_custom_item,
get_custom_item_by_id,
get_custom_items,
update_custom_item,
)
# Re-export make_intervals_request and httpx_client for backward compatibility
# pylint: disable=duplicate-code # This __all__ list is intentionally similar to tools/__init__.py
__all__ = [
"make_intervals_request",
"httpx_client", # Re-exported for test compatibility
"add_activity_message",
"get_activities",
"get_activity_details",
"get_activity_intervals",
"get_activity_messages",
"get_activity_streams",
"get_events",
"get_event_by_id",
"delete_event",
"delete_events_by_date_range",
"add_or_update_event",
"get_wellness_data",
"get_athlete_power_curves",
"get_custom_items",
"get_custom_item_by_id",
"create_custom_item",
"update_custom_item",
"delete_custom_item",
]
# Run the server
if __name__ == "__main__":
# Validate ATHLETE_ID when server starts (not at import time to allow tests)
validate_athlete_id(config.athlete_id)
# Setup transport and start server
selected_transport = setup_transport()
start_server(mcp, selected_transport)
+78
View File
@@ -0,0 +1,78 @@
"""
Server setup and initialization for Intervals.icu MCP Server.
This module handles transport configuration and server startup logic.
"""
import os
import logging
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
from intervals_mcp_server.utils.types import TransportAliases
logger = logging.getLogger("intervals_icu_mcp_server")
def setup_transport() -> TransportAliases:
"""
Setup and validate the MCP transport configuration.
Reads MCP_TRANSPORT environment variable and validates it against
supported transport types.
Returns:
TransportAliases: The selected transport type.
Raises:
ValueError: If the transport type is not supported.
"""
transport_env = os.getenv("MCP_TRANSPORT", TransportAliases.STDIO.value).lower()
try:
transport_alias = TransportAliases(transport_env)
except ValueError as exc:
allowed = ", ".join(item.value for item in TransportAliases)
raise ValueError(f"Unsupported MCP_TRANSPORT value. Use one of: {allowed}.") from exc
# Map HTTP to STREAMABLE_HTTP
selected_transport = (
TransportAliases.STREAMABLE_HTTP
if transport_alias == TransportAliases.HTTP
else transport_alias
)
return selected_transport
def start_server(mcp_instance: FastMCP, transport: TransportAliases) -> None:
"""
Start the MCP server with the specified transport.
Args:
mcp_instance (FastMCP): The FastMCP server instance to start.
transport (TransportAliases): The transport type to use.
"""
host = mcp_instance.settings.host
port = mcp_instance.settings.port
if transport == TransportAliases.STDIO:
logger.info("Starting MCP server with stdio transport.")
mcp_instance.run()
elif transport == TransportAliases.SSE:
mount_path = os.getenv("MCP_SSE_MOUNT_PATH")
logger.info(
"Starting MCP server with SSE transport at http://%s:%s%s (messages: %s).",
host,
port,
mcp_instance.settings.sse_path,
mcp_instance.settings.message_path,
)
mcp_instance.run(transport="sse", mount_path=mount_path)
else: # STREAMABLE_HTTP
logger.info(
"Starting MCP server with Streamable HTTP transport at http://%s:%s%s.",
host,
port,
mcp_instance.settings.streamable_http_path,
)
mcp_instance.run(transport="streamable-http")
@@ -0,0 +1,73 @@
"""
MCP tools registry for Intervals.icu MCP Server.
This module registers all available MCP tools with the FastMCP server instance.
"""
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
# Import all tools for re-export
# Note: Tools register themselves via @mcp.tool() decorators when imported
from intervals_mcp_server.tools.activities import ( # noqa: F401
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_streams,
)
from intervals_mcp_server.tools.events import ( # noqa: F401
add_or_update_event,
delete_event,
delete_events_by_date_range,
get_event_by_id,
get_events,
)
from intervals_mcp_server.tools.custom_items import ( # noqa: F401
create_custom_item,
delete_custom_item,
get_custom_item_by_id,
get_custom_items,
update_custom_item,
)
from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves,
)
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401
def register_tools(mcp_instance: FastMCP) -> None:
"""
Register all MCP tools with the FastMCP server instance.
This function imports all tool modules, which causes their @mcp.tool()
decorators to register the tools. The tools need access to the mcp instance,
so they will be imported after the mcp instance is created.
Args:
mcp_instance (FastMCP): The FastMCP server instance to register tools with.
"""
# Tools are registered via decorators when modules are imported above
# The mcp_instance parameter is kept for future use if needed
_ = mcp_instance
__all__ = [
"register_tools",
"get_activities",
"get_activity_details",
"get_activity_intervals",
"get_activity_streams",
"get_events",
"get_event_by_id",
"delete_event",
"delete_events_by_date_range",
"add_or_update_event",
"get_custom_items",
"get_custom_item_by_id",
"create_custom_item",
"update_custom_item",
"delete_custom_item",
"get_athlete_power_curves",
"get_gear_list",
"get_wellness_data",
]
@@ -0,0 +1,394 @@
"""
Activity-related MCP tools for Intervals.icu.
This module contains tools for retrieving and managing athlete activities.
"""
from datetime import datetime, timedelta
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.tools.gear import (
resolve_gear_for_activity,
resolve_gear_for_activities,
)
from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]:
"""Extract a list of activity dictionaries from the API result."""
activities: list[dict[str, Any]] = []
if isinstance(result, list):
activities = [item for item in result if isinstance(item, dict)]
elif isinstance(result, dict):
# Result is a single activity or a container
for _key, value in result.items():
if isinstance(value, list):
activities = [item for item in value if isinstance(item, dict)]
break
# If no list was found but the dict has typical activity fields, treat it as a single activity
if not activities and any(key in result for key in ["name", "startTime", "distance"]):
activities = [result]
return activities
def _filter_named_activities(activities: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Filter out unnamed activities from the list."""
return [
activity
for activity in activities
if activity.get("name") and activity.get("name") != "Unnamed"
]
async def _fetch_more_activities(
athlete_id: str,
start_date: str,
api_key: str | None,
api_limit: int,
) -> list[dict[str, Any]]:
"""Fetch additional activities from an earlier date range."""
oldest_date = datetime.fromisoformat(start_date)
older_start_date = (oldest_date - timedelta(days=60)).strftime("%Y-%m-%d")
older_end_date = (oldest_date - timedelta(days=1)).strftime("%Y-%m-%d")
if older_start_date >= older_end_date:
return []
more_params = {
"oldest": older_start_date,
"newest": older_end_date,
"limit": api_limit,
}
more_result = await make_intervals_request(
url=f"/athlete/{athlete_id}/activities",
api_key=api_key,
params=more_params,
)
if isinstance(more_result, list):
return _filter_named_activities(more_result)
return []
def _format_activities_response(
activities: list[dict[str, Any]],
athlete_id: str,
include_unnamed: bool,
) -> str:
"""Format the activities response based on the results."""
if not activities:
if include_unnamed:
return (
f"No valid activities found for athlete {athlete_id} in the specified date range."
)
return f"No named activities found for athlete {athlete_id} in the specified date range. Try with include_unnamed=True to see all activities."
# Format the output
activities_summary = "Activities:\n\n"
for activity in activities:
if isinstance(activity, dict):
activities_summary += format_activity_summary(activity) + "\n"
else:
activities_summary += f"Invalid activity format: {activity}\n\n"
return activities_summary
@mcp.tool()
async def get_activities( # pylint: disable=too-many-arguments,too-many-return-statements,too-many-branches,too-many-positional-arguments
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
limit: int = 10,
include_unnamed: bool = False,
) -> str:
"""Get a list of activities for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago)
end_date: End date in YYYY-MM-DD format (optional, defaults to today)
limit: Maximum number of activities to return (optional, defaults to 10)
include_unnamed: Whether to include unnamed activities (optional, defaults to False)
"""
# Resolve athlete ID and date parameters
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
start_date, end_date = resolve_date_params(start_date, end_date)
# Fetch more activities if we need to filter out unnamed ones
api_limit = limit * 3 if not include_unnamed else limit
# Call the Intervals.icu API
params = {"oldest": start_date, "newest": end_date, "limit": api_limit}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/activities", api_key=api_key, params=params
)
# Check for error
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activities: {error_message}"
if not result:
return f"No activities found for athlete {athlete_id_to_use} in the specified date range."
# Parse activities from result
activities = _parse_activities_from_result(result)
if not activities:
return f"No valid activities found for athlete {athlete_id_to_use} in the specified date range."
# Filter and fetch more if needed
if not include_unnamed:
activities = _filter_named_activities(activities)
# If we don't have enough named activities, try to fetch more
if len(activities) < limit:
more_activities = await _fetch_more_activities(
athlete_id_to_use, start_date, api_key, api_limit
)
activities.extend(more_activities)
# Limit to requested count
activities = activities[:limit]
# Resolve gear names (in-place injection of `_resolved_gear_name`)
await resolve_gear_for_activities(
activities, athlete_id=athlete_id_to_use, api_key=api_key
)
return _format_activities_response(activities, athlete_id_to_use, include_unnamed)
@mcp.tool()
async def get_activity_details(activity_id: str, api_key: str | None = None) -> str:
"""Get detailed information for a specific activity from Intervals.icu
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity details: {error_message}"
# Format the response
if not result:
return f"No details found for activity {activity_id}."
# If result is a list, use the first item if available
activity_data = result[0] if isinstance(result, list) and result else result
if not isinstance(activity_data, dict):
return f"Invalid activity format for activity {activity_id}."
# Resolve gear name (uses configured athlete_id via ATHLETE_ID env var)
await resolve_gear_for_activity(activity_data, api_key=api_key)
# Return a more detailed view of the activity
detailed_view = format_activity_summary(activity_data)
# Add additional details if available
if "zones" in activity_data:
zones = activity_data["zones"]
detailed_view += "\nPower Zones:\n"
for zone in zones.get("power", []):
detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n"
detailed_view += "\nHeart Rate Zones:\n"
for zone in zones.get("hr", []):
detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n"
return detailed_view
@mcp.tool()
async def get_activity_intervals(activity_id: str, api_key: str | None = None) -> str:
"""Get interval data for a specific activity from Intervals.icu
This endpoint returns detailed metrics for each interval in an activity, including power, heart rate,
cadence, speed, and environmental data. It also includes grouped intervals if applicable.
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# Call the Intervals.icu API
result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching intervals: {error_message}"
# Format the response
if not result:
return f"No interval data found for activity {activity_id}."
# If the result is empty or doesn't contain expected fields
if not isinstance(result, dict) or not any(
key in result for key in ["icu_intervals", "icu_groups"]
):
return f"No interval data or unrecognized format for activity {activity_id}."
# Format the intervals data
return format_intervals(result)
@mcp.tool()
async def get_activity_streams(
activity_id: str,
api_key: str | None = None,
stream_types: str | None = None,
) -> str:
"""Get stream data for a specific activity from Intervals.icu
This endpoint returns time-series data for an activity, including metrics like power, heart rate,
cadence, altitude, distance, temperature, and velocity data.
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
stream_types: Comma-separated list of stream types to retrieve (optional, defaults to all available types)
Available types: time, watts, heartrate, cadence, altitude, distance,
core_temperature, skin_temperature, velocity_smooth
"""
# Build query parameters
params = {}
if stream_types:
params["types"] = stream_types
else:
# Default to common stream types if none specified
params["types"] = "time,watts,heartrate,cadence,altitude,distance,velocity_smooth"
# Call the Intervals.icu API
result = await make_intervals_request(
url=f"/activity/{activity_id}/streams",
api_key=api_key,
params=params,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity streams: {error_message}"
# Format the response
if not result:
return f"No stream data found for activity {activity_id}."
# Ensure result is a list
streams = result if isinstance(result, list) else []
if not streams:
return f"No stream data found for activity {activity_id}."
# Format the streams data
streams_summary = f"Activity Streams for {activity_id}:\n\n"
for stream in streams:
if not isinstance(stream, dict):
continue
stream_type = stream.get("type", "unknown")
stream_name = stream.get("name", stream_type)
data = stream.get("data", [])
value_type = stream.get("valueType", "")
streams_summary += f"Stream: {stream_name} ({stream_type})\n"
streams_summary += f" Value Type: {value_type}\n"
streams_summary += f" Data Points: {len(data)}\n"
# Show first few and last few data points for preview
if data:
if len(data) <= 10:
streams_summary += f" Values: {data}\n"
else:
preview_start = data[:5]
preview_end = data[-5:]
streams_summary += f" First 5 values: {preview_start}\n"
streams_summary += f" Last 5 values: {preview_end}\n"
streams_summary += "\n"
return streams_summary
@mcp.tool()
async def get_activity_messages(activity_id: str, api_key: str | None = None) -> str:
"""Get messages (notes/comments) for a specific activity from Intervals.icu
Args:
activity_id: The Intervals.icu activity ID
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
result = await make_intervals_request(
url=f"/activity/{activity_id}/messages",
api_key=api_key,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching activity messages: {error_message}"
if not result:
return f"No messages found for activity {activity_id}."
messages = result if isinstance(result, list) else []
if not messages:
return f"No messages found for activity {activity_id}."
output = f"Messages for activity {activity_id}:\n\n"
for msg in messages:
if isinstance(msg, dict):
output += format_activity_message(msg) + "\n\n"
return output
@mcp.tool()
async def add_activity_message(
activity_id: str,
content: str,
api_key: str | None = None,
) -> str:
"""Add a message (note/comment) to an activity on Intervals.icu
Args:
activity_id: The Intervals.icu activity ID
content: The message text to add
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
result = await make_intervals_request(
url=f"/activity/{activity_id}/messages",
api_key=api_key,
method="POST",
data={"content": content},
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error adding message to activity: {error_message}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when adding message."
msg_id = result.get("id")
if msg_id is not None:
return f"Successfully added message (ID: {msg_id}) to activity {activity_id}."
return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually."
@@ -0,0 +1,232 @@
"""
Custom items MCP tools for Intervals.icu.
This module contains tools for managing athlete custom items (charts, fields, zones, etc.).
"""
import json
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_custom_item_details
from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool()
async def get_custom_items(
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom items: {result.get('message')}"
if not result:
return f"No custom items found for athlete {athlete_id_to_use}."
output = "Custom Items:\n\n"
for item in result:
if isinstance(item, dict):
output += f"- ID: {item.get('id')}\n"
output += f" Name: {item.get('name', 'N/A')}\n"
output += f" Type: {item.get('type', 'N/A')}\n"
if item.get("description"):
output += f" Description: {item['description']}\n"
output += "\n"
return output
@mcp.tool()
async def get_custom_item_by_id(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get detailed information for a specific custom item from Intervals.icu
Args:
item_id: The custom item ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return f"No custom item found with ID {item_id}."
return format_custom_item_details(result)
@mcp.tool()
async def create_custom_item(
name: str,
item_type: str,
athlete_id: str | None = None,
api_key: str | None = None,
description: str | None = None,
content: dict[str, Any] | None = None,
visibility: str | None = None,
) -> str:
"""Create a new custom item for an athlete on Intervals.icu
Args:
name: Name of the custom item
item_type: Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES)
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
description: Description of the custom item (optional)
content: Configuration content for the custom item as a dict (optional). Important enum values:
- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
data: dict[str, Any] = {"name": name, "type": item_type}
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item",
api_key=api_key,
data=data,
method="POST",
)
if isinstance(result, dict) and "error" in result:
return f"Error creating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when creating custom item."
return f"Successfully created custom item:\n\n{format_custom_item_details(result)}"
@mcp.tool()
async def update_custom_item(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
name: str | None = None,
item_type: str | None = None,
description: str | None = None,
content: dict[str, Any] | None = None,
visibility: str | None = None,
) -> str:
"""Update an existing custom item for an athlete on Intervals.icu
Args:
item_id: The custom item ID to update
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
name: New name for the custom item (optional)
item_type: New type for the custom item (optional)
description: New description for the custom item (optional)
content: New configuration content for the custom item as a dict (optional). Important enum values:
- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")
- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")
visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
data: dict[str, Any] = {}
if name is not None:
data["name"] = name
if item_type is not None:
data["type"] = item_type
if description is not None:
data["description"] = description
if content is not None:
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
return "Error: content must be valid JSON when passed as a string."
data["content"] = content
if visibility is not None:
data["visibility"] = visibility
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
data=data,
method="PUT",
)
if isinstance(result, dict) and "error" in result:
return f"Error updating custom item: {result.get('message')}"
if not result or not isinstance(result, dict):
return "Error: Unexpected response when updating custom item."
return f"Successfully updated custom item:\n\n{format_custom_item_details(result)}"
@mcp.tool()
async def delete_custom_item(
item_id: int,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete a custom item for an athlete from Intervals.icu
Args:
item_id: The custom item ID to delete
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting custom item: {result.get('message')}"
return f"Successfully deleted custom item {item_id}."
+433
View File
@@ -0,0 +1,433 @@
"""
Event-related MCP tools for Intervals.icu.
This module contains tools for retrieving, creating, updating, and deleting athlete events.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.dates import get_default_end_date, get_default_future_end_date
from intervals_mcp_server.utils.formatting import format_event_details, format_event_summary
from intervals_mcp_server.utils.types import WorkoutDoc
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id, validate_date
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
def _prepare_event_data( # pylint: disable=too-many-arguments,too-many-positional-arguments
name: str,
workout_type: str,
start_date: str,
workout_doc: WorkoutDoc | None,
moving_time: int | None,
distance: int | None,
) -> dict[str, Any]:
"""Prepare event data dictionary for API request.
Many arguments are required to match the Intervals.icu API event structure.
"""
resolved_workout_type = resolve_activity_type(name, workout_type)
return {
"start_date_local": start_date + "T00:00:00",
"category": "WORKOUT",
"name": name,
"description": str(workout_doc) if workout_doc else None,
"type": resolved_workout_type,
"moving_time": moving_time,
"distance": distance,
}
def _handle_event_response(
result: dict[str, Any] | list[dict[str, Any]] | None,
action: str,
athlete_id: str,
start_date: str,
) -> str:
"""Handle API response and format appropriate message."""
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error {action} event: {error_message}"
if not result:
return f"No events {action} for athlete {athlete_id}."
if isinstance(result, dict):
return f"Successfully {action} event id: {result.get('id')}"
return f"Event {action} successfully at {start_date}"
async def _delete_events_list(
athlete_id: str, api_key: str | None, events: list[dict[str, Any]]
) -> list[int | str | None]:
"""Delete a list of events and return IDs of failed deletions.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
events: List of event dictionaries to delete.
Returns:
List of event IDs that failed to delete.
"""
failed_events: list[int | str | None] = []
for event in events:
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events/{event.get('id')}",
api_key=api_key,
method="DELETE",
)
if isinstance(result, dict) and "error" in result:
failed_events.append(event.get("id"))
return failed_events
@mcp.tool()
async def get_events(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""Get events for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today)
"""
# Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
# Parse date parameters (events use different defaults)
if not start_date:
start_date = get_default_end_date()
if not end_date:
end_date = get_default_future_end_date()
# Call the Intervals.icu API
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching events: {error_message}"
# Format the response
if not result:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
# Ensure result is a list
events = result if isinstance(result, list) else []
if not events:
return f"No events found for athlete {athlete_id_to_use} in the specified date range."
events_summary = "Events:\n\n"
for event in events:
if not isinstance(event, dict):
continue
events_summary += format_event_summary(event) + "\n\n"
return events_summary
@mcp.tool()
async def get_event_by_id(
event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get detailed information for a specific event from Intervals.icu
Args:
event_id: The Intervals.icu event ID
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
"""
# Resolve athlete ID
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
# Call the Intervals.icu API
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/event/{event_id}", api_key=api_key
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching event details: {error_message}"
# Format the response
if not result:
return f"No details found for event {event_id}."
if not isinstance(result, dict):
return f"Invalid event format for event {event_id}."
return format_event_details(result)
@mcp.tool()
async def delete_event(
event_id: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete event for an athlete from Intervals.icu
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not event_id:
return "Error: No event ID provided."
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/events/{event_id}", api_key=api_key, method="DELETE"
)
if isinstance(result, dict) and "error" in result:
return f"Error deleting event: {result.get('message')}"
return json.dumps(result, indent=2)
async def _fetch_events_for_deletion(
athlete_id: str, api_key: str | None, start_date: str, end_date: str
) -> tuple[list[dict[str, Any]], str | None]:
"""Fetch events for deletion and return them with any error message.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
Tuple of (events_list, error_message). error_message is None if successful.
"""
params = {"oldest": validate_date(start_date), "newest": validate_date(end_date)}
result = await make_intervals_request(
url=f"/athlete/{athlete_id}/events", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return [], f"Error deleting events: {result.get('message')}"
events = result if isinstance(result, list) else []
return events, None
@mcp.tool()
async def delete_events_by_date_range(
start_date: str,
end_date: str,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Delete events for an athlete from Intervals.icu in the specified date range.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
events, error_msg = await _fetch_events_for_deletion(
athlete_id_to_use, api_key, start_date, end_date
)
if error_msg:
return error_msg
failed_events = await _delete_events_list(athlete_id_to_use, api_key, events)
deleted_count = len(events) - len(failed_events)
return f"Deleted {deleted_count} events. Failed to delete {len(failed_events)} events: {failed_events}"
@mcp.tool()
async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-positional-arguments
workout_type: str,
name: str,
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None,
start_date: str | None = None,
workout_doc: WorkoutDoc | None = None,
moving_time: int | None = None,
distance: int | None = None,
) -> str:
"""Post event for an athlete to Intervals.icu this follows the event api from intervals.icu
If event_id is provided, the event will be updated instead of created.
Many arguments are required as this MCP tool function maps directly to the Intervals.icu API parameters.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
event_id: The Intervals.icu event ID (optional, will use event_id from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
name: Name of the activity
workout_doc: steps as a list of Step objects (optional, but necessary to define workout steps)
workout_type: Workout type (e.g. Ride, Run, Swim, Walk, Row)
moving_time: Total expected moving time of the workout in seconds (optional)
distance: Total expected distance of the workout in meters (optional)
Example:
"workout_doc": {
"description": "High-intensity workout for increasing VO2 max",
"steps": [
{"power": {"value": 80, "units": "%ftp"}, "duration": 900, "warmup": true},
{"reps": 2, "text": "High-intensity intervals", "steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]},
{"power": {"value": 80, "units": "%ftp"}, "duration": 600, "cooldown": true},
{"text": ""}
]
}
Step properties:
distance: Distance of step in meters
{"distance": 5000}
duration: Duration of step in seconds
{"duration": 1800}
power/hr/pace/cadence: Define step intensity
Percentage of FTP: {"power": {"value": 80, "units": "%ftp"}}
Absolute power: {"power": {"value": 200, "units": "w"}}
Heart rate: {"hr": {"value": 75, "units": "%hr"}}
Heart rate (LTHR): {"hr": {"value": 85, "units": "%lthr"}}
Cadence: {"cadence": {"value": 90, "units": "cadence"}}
Pace by ftp: {"pace": {"value": 80, "units": "%pace"}}
Pace by zone: {"pace": {"value": 2, "units": "pace_zone"}}
Zone by power: {"power": {"value": 2, "units": "power_zone"}}
Zone by heart rate: {"hr": {"value": 2, "units": "hr_zone"}}
Ranges: Specify ranges for power, heart rate, or cadence:
{"power": {"start": 80, "end": 90, "units": "%ftp"}}
Ramps: Instead of a range, indicate a gradual change in intensity (useful for ERG workouts):
{"ramp": true, "power": {"start": 80, "end": 90, "units": "%ftp"}}
Repeats: include the reps property and add nested steps
{"reps": 3,
"steps": [
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
]}
Free Ride: Include freeride to indicate a segment without ERG control, optionally with a suggested power range:
{"freeride": true, "power": {"value": 80, "units": "%ftp"}}
Comments and Labels: Add descriptive text to label steps:
{"text": "Warmup"}
How to use steps:
- Set distance or duration as appropriate for step
- Use "reps" with nested steps to define repeat intervals (as in example above)
- Define one of "power", "hr" or "pace" to define step intensity
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = _prepare_event_data(
name, workout_type, validated_date, workout_doc, moving_time, distance
)
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
@mcp.tool()
async def add_or_update_note(
name: str,
description: str,
start_date: str | None = None,
color: str | None = "green",
athlete_id: str | None = None,
api_key: str | None = None,
event_id: str | None = None,
) -> str:
"""Add or update a plain text note (category NOTE) on the Intervals.icu calendar.
Args:
name: Title of the note
description: Plain text content of the note
start_date: Date in YYYY-MM-DD format (optional, defaults to today)
color: Color of the note (e.g. green, orange, red, blue)
athlete_id: The Intervals.icu athlete ID (optional)
api_key: The Intervals.icu API key (optional)
event_id: The Intervals.icu event ID (optional, for updates)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
try:
validated_date = validate_date(start_date)
event_data = {
"category": "NOTE",
"name": name,
"description": description,
"start_date_local": validated_date + "T00:00:00",
"color": color
}
return await _create_or_update_event_request(
athlete_id_to_use, api_key, event_data, validated_date, event_id
)
except ValueError as e:
return f"Error: {e}"
async def _create_or_update_event_request(
athlete_id: str,
api_key: str | None,
event_data: dict[str, Any],
start_date: str,
event_id: str | None,
) -> str:
"""Create or update an event via API request.
Args:
athlete_id: The athlete ID.
api_key: Optional API key.
event_data: Prepared event data dictionary.
start_date: Start date string for response formatting.
event_id: Optional event ID for updates.
Returns:
Formatted response string.
"""
url = f"/athlete/{athlete_id}/events"
if event_id:
url += f"/{event_id}"
result = await make_intervals_request(
url=url,
api_key=api_key,
data=event_data,
method="PUT" if event_id else "POST",
)
action = "updated" if event_id else "created"
return _handle_event_response(result, action, athlete_id, start_date)
+198
View File
@@ -0,0 +1,198 @@
"""
Gear-related MCP tools for Intervals.icu.
This module provides:
- A module-level cache of the athlete's raw gear catalog (bikes, shoes, etc.)
to avoid hitting the /athlete/{id}/gear endpoint on every activity lookup.
- A helper to inject the human-readable gear name into an activity dict (under
`_resolved_gear_name`), which the formatter then displays in the `Gear:` block.
- A user-facing MCP tool `get_gear_list` so the assistant can discover or
refresh the gear catalog on demand.
Intervals.icu's activity payload includes only the gear ID (e.g. `b16177481`)
but not the gear name. The gear name lives in a separate endpoint
`/athlete/{athlete_id}/gear` that returns the full catalog. To avoid an extra
round-trip per activity, we cache the raw gear catalog per athlete for the
lifetime of the MCP server process and derive the `{id: name}` lookup from it.
Call `get_gear_list(refresh=True)` to bust the cache.
"""
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.validation import resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
# Module-level cache of the raw gear catalog per athlete. Single source of
# truth: the id->name map and the rich listing are both derived from this.
_GEAR_RAW_CACHE: dict[str, list[dict[str, Any]]] = {}
def _extract_gear_id(activity: dict[str, Any]) -> str | None:
"""Pull the gear ID out of an activity dict, handling the two known shapes."""
gear_raw = activity.get("gear")
if isinstance(gear_raw, dict):
gear_id = gear_raw.get("id")
if gear_id:
return str(gear_id)
gear_id = activity.get("gear_id")
if gear_id:
return str(gear_id)
return None
def _items_from_response(result: Any) -> list[dict[str, Any]]:
"""Normalize the /athlete/{id}/gear response into a list of gear dicts."""
if isinstance(result, list):
return [item for item in result if isinstance(item, dict)]
if isinstance(result, dict):
# Some endpoints wrap the list in a container; pull any list value.
for value in result.values():
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _derive_gear_map(items: list[dict[str, Any]]) -> dict[str, str]:
"""Convert a raw gear list into a {gear_id: gear_name} lookup."""
gear_map: dict[str, str] = {}
for item in items:
gid = item.get("id")
name = item.get("name") or item.get("display_name")
if gid and name:
gear_map[str(gid)] = str(name)
return gear_map
async def get_gear_raw(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> list[dict[str, Any]]:
"""Return (and cache) the raw gear list for an athlete.
Single source of truth that backs both the id->name map and the rich
listing produced by `get_gear_list`. One API call per athlete per process
lifetime unless `refresh=True`.
Args:
athlete_id: Athlete to look up. Defaults to ATHLETE_ID env var via config.
api_key: Override the configured API key.
refresh: If True, ignore the cache and re-fetch from the API.
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg or not athlete_id_to_use:
return []
if not refresh and athlete_id_to_use in _GEAR_RAW_CACHE:
return _GEAR_RAW_CACHE[athlete_id_to_use]
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/gear", api_key=api_key
)
items = _items_from_response(result)
_GEAR_RAW_CACHE[athlete_id_to_use] = items
return items
async def get_gear_map(
athlete_id: str | None = None,
api_key: str | None = None,
*,
refresh: bool = False,
) -> dict[str, str]:
"""Return the {gear_id: gear_name} lookup for an athlete (derived from cache)."""
items = await get_gear_raw(athlete_id=athlete_id, api_key=api_key, refresh=refresh)
return _derive_gear_map(items)
async def resolve_gear_for_activity(
activity: dict[str, Any],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into an activity dict if gear info is present.
Mutates the activity dict in place. Safe to call when gear is absent (no-op).
Uses the cached gear map; the first call per athlete triggers a fetch.
"""
gear_id = _extract_gear_id(activity)
if not gear_id:
return
gear_map = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
name = gear_map.get(gear_id)
if name:
activity["_resolved_gear_name"] = name
async def resolve_gear_for_activities(
activities: list[dict[str, Any]],
athlete_id: str | None = None,
api_key: str | None = None,
) -> None:
"""Inject `_resolved_gear_name` into each activity in a list. In-place."""
if not activities:
return
# Pre-warm the cache once, then iterate.
_ = await get_gear_map(athlete_id=athlete_id, api_key=api_key)
for activity in activities:
if isinstance(activity, dict):
await resolve_gear_for_activity(
activity, athlete_id=athlete_id, api_key=api_key
)
@mcp.tool()
async def get_gear_list(
athlete_id: str | None = None,
api_key: str | None = None,
refresh: bool = False,
) -> str:
"""Get the gear catalog (bikes, shoes, etc.) for an athlete from Intervals.icu.
Returns one line per gear item with id, type, name, and basic stats.
The result is cached for the MCP process lifetime; pass refresh=True to
re-fetch.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
refresh: If True, bypass the cache and re-fetch from the API (default False)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if not athlete_id_to_use:
return "Error: athlete_id is required (either as argument or via ATHLETE_ID env var)."
# Single fetch path: get_gear_raw consults the cache and only hits the API
# on a cold cache or when refresh=True.
items = await get_gear_raw(
athlete_id=athlete_id_to_use, api_key=api_key, refresh=refresh
)
if not items:
return f"No gear found for athlete {athlete_id_to_use}."
output = f"Gear catalog for athlete {athlete_id_to_use}:\n\n"
output += f"{'ID':<14} {'Type':<8} {'Name':<32} {'Default':<8} {'Acts':<6} {'Dist (km)':<10} {'Retired':<8}\n"
output += f"{'-' * 14} {'-' * 8} {'-' * 32} {'-' * 8} {'-' * 6} {'-' * 10} {'-' * 8}\n"
for it in items:
gid = str(it.get("id", "?"))
gtype = str(it.get("component_type", it.get("type", "?")))
name = str(it.get("name", "?"))[:32]
default_for = it.get("default_for_type") or it.get("default_for") or ""
acts = str(it.get("activities", it.get("activity_count", "?")))
dist_m = it.get("distance", 0) or 0
dist_km = f"{dist_m / 1000:.1f}" if isinstance(dist_m, (int, float)) else "?"
retired = "yes" if it.get("retired") else ""
output += f"{gid:<14} {gtype:<8} {name:<32} {str(default_for):<8} {acts:<6} {dist_km:<10} {retired:<8}\n"
return output
@@ -0,0 +1,214 @@
"""
Power curve MCP tools for Intervals.icu.
This module contains tools for retrieving athlete power curve data.
"""
import json
from datetime import datetime
from typing import Any
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_power_curves
from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
# 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min
DEFAULT_DURATIONS: tuple[int, ...] = (5, 15, 30, 60, 120, 300, 600, 1200, 3600)
def _build_curves_param(
this_season: bool,
last_season: bool,
start_date: str | None,
end_date: str | None,
) -> list[str]:
"""Build the curves query parameter list based on user selections.
Args:
this_season: Whether to include this season's curve.
last_season: Whether to include last season's curve.
start_date: Optional start date for a custom date range curve.
end_date: Optional end date for a custom date range curve.
Returns:
List of curve identifiers for the API request.
"""
curves: list[str] = []
if this_season:
curves.append("s0")
if last_season:
curves.append("s1")
if start_date and end_date:
curves.append(f"r.{start_date}.{end_date}")
return curves
def _validate_dates(start_date: str | None, end_date: str | None) -> str | None:
"""Validate that start_date and end_date are either both provided or both absent.
Returns:
An error message if validation fails, otherwise None.
"""
if (start_date is None) != (end_date is None):
return "Error: Both start_date and end_date must be provided together for a custom date range."
if start_date and end_date:
try:
s = datetime.strptime(start_date, "%Y-%m-%d")
e = datetime.strptime(end_date, "%Y-%m-%d")
if s >= e:
return "Error: start_date must be before end_date."
except ValueError:
return "Error: Dates must be in YYYY-MM-DD format."
return None
def _extract_curve_data(
curve: dict[str, Any],
durations: list[int],
include_normalised: bool,
) -> dict[str, Any]:
"""Extract power data for requested durations from a single curve.
Args:
curve: A single curve object from the API response.
durations: List of durations in seconds to extract.
include_normalised: Whether to include W/kg data.
Returns:
Dictionary with curve metadata and extracted data points.
"""
secs = curve.get("secs", [])
values = curve.get("values", [])
activity_ids = curve.get("activity_id", [])
watts_per_kg = curve.get("watts_per_kg", [])
wkg_activity_ids = curve.get("wkg_activity_id", [])
# Build a lookup from seconds to index for efficient access
sec_to_idx: dict[int, int] = {s: i for i, s in enumerate(secs)}
data_points: list[dict[str, Any]] = []
for dur in durations:
idx = sec_to_idx.get(dur)
if idx is None or idx >= len(values):
continue
point: dict[str, Any] = {
"secs": dur,
"watts": values[idx],
"activity_id": (
activity_ids[idx]
if idx < len(activity_ids) and activity_ids[idx] is not None
else ""
),
}
if include_normalised and idx < len(watts_per_kg):
point["watts_per_kg"] = round(watts_per_kg[idx], 2)
point["wkg_activity_id"] = (
wkg_activity_ids[idx]
if idx < len(wkg_activity_ids)
and wkg_activity_ids[idx] is not None
else ""
)
data_points.append(point)
return {
"id": curve.get("id", ""),
"label": curve.get("label", curve.get("id", "")),
"start": curve.get("start_date_local", ""),
"end": curve.get("end_date_local", ""),
"data_points": data_points,
}
@mcp.tool()
async def get_athlete_power_curves(
activity_type: str = "Ride",
durations: list[int] | None = None,
indoor_outdoor: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
this_season: bool = True,
last_season: bool = True,
include_normalised: bool = True,
athlete_id: str | None = None,
api_key: str | None = None,
) -> str:
"""Get power curves for an athlete from Intervals.icu.
Returns best power output for selected durations across specified time periods.
Uses FFT power computation. Power values are in watts.
Args:
activity_type: Activity type (e.g. "Ride", "Run", "VirtualRide"). Default is "Ride".
durations: Durations in seconds to include. Default is [5, 15, 30, 60, 120, 300, 600, 1200, 3600]
indoor_outdoor: Filter by location — "indoor" or "outdoor". Omit for no filtering.
start_date: Start date (YYYY-MM-DD) for custom date range curve. Must be used with end_date.
end_date: End date (YYYY-MM-DD) for custom date range curve. Must be used with start_date.
this_season: Include this season's curve (default True)
last_season: Include last season's curve (default True)
include_normalised: Include weight-normalised W/kg values (default True)
athlete_id: Intervals.icu athlete ID (optional, uses ATHLETE_ID from .env if not provided)
api_key: Optional API key override. Uses API_KEY from .env if not provided.
"""
if durations is None:
durations = list(DEFAULT_DURATIONS)
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"):
return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted."
date_error = _validate_dates(start_date, end_date)
if date_error:
return date_error
curves = _build_curves_param(this_season, last_season, start_date, end_date)
if not curves:
return "Error: At least one curve must be selected (this_season, last_season, or a date range)."
params: dict[str, Any] = {
"curves": curves,
"type": activity_type,
"includeRanks": False,
}
if indoor_outdoor:
params["filters"] = json.dumps(
[{"field_id": "indoor", "value": indoor_outdoor, "id": 1}]
)
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/power-curves",
params=params,
api_key=api_key,
)
if isinstance(result, dict) and "error" in result:
error_message = result.get("message", "Unknown error")
return f"Error fetching power curves: {error_message}"
# Response has a "list" key containing curve objects
curve_list: list[dict[str, Any]] = []
if isinstance(result, dict):
curve_list = result.get("list", [])
elif isinstance(result, list):
curve_list = result
if not curve_list:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
extracted: list[dict[str, Any]] = []
for curve in curve_list:
if isinstance(curve, dict):
extracted.append(_extract_curve_data(curve, durations, include_normalised))
if not extracted:
return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})."
return format_power_curves(extracted, activity_type, include_normalised)
@@ -0,0 +1,71 @@
"""
Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.config import get_config
from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
config = get_config()
@mcp.tool()
async def get_wellness_data(
athlete_id: str | None = None,
api_key: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
include_all_fields: bool = False,
) -> str:
"""Get wellness data for an athlete from Intervals.icu.
By default returns standard wellness fields (training metrics, vitals, sleep,
subjective scores, etc.). Set include_all_fields=True to also include any
additional or custom fields configured by the user in Intervals.icu.
Args:
athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided)
api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided)
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago)
end_date: End date in YYYY-MM-DD format (optional, defaults to today)
include_all_fields: If True, include additional and custom fields beyond the standard set (optional, defaults to False)
"""
athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id)
if error_msg:
return error_msg
start_date, end_date = resolve_date_params(start_date, end_date)
params = {"oldest": start_date, "newest": end_date}
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params
)
if isinstance(result, dict) and "error" in result:
return f"Error fetching wellness data: {result.get('message')}"
if not result:
return (
f"No wellness data found for athlete {athlete_id_to_use} in the specified date range."
)
wellness_summary = "Wellness Data:\n\n"
if isinstance(result, dict):
for date_str, data in result.items():
if isinstance(data, dict) and "date" not in data:
data["date"] = date_str
wellness_summary += format_wellness_entry(data, include_all_fields=include_all_fields) + "\n\n"
elif isinstance(result, list):
for entry in result:
if isinstance(entry, dict):
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary
+64
View File
@@ -0,0 +1,64 @@
"""
Date utility functions for Intervals.icu MCP Server.
This module provides helper functions for date parsing and default date calculations.
"""
from datetime import datetime, timedelta
def get_default_start_date(days_ago: int = 30) -> str:
"""
Get a default start date string in YYYY-MM-DD format.
Args:
days_ago: Number of days ago from today. Defaults to 30.
Returns:
Date string in YYYY-MM-DD format.
"""
return (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d")
def get_default_end_date() -> str:
"""
Get today's date string in YYYY-MM-DD format.
Returns:
Date string in YYYY-MM-DD format.
"""
return datetime.now().strftime("%Y-%m-%d")
def get_default_future_end_date(days_ahead: int = 30) -> str:
"""
Get a default future end date string in YYYY-MM-DD format.
Args:
days_ahead: Number of days ahead from today. Defaults to 30.
Returns:
Date string in YYYY-MM-DD format.
"""
return (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
def parse_date_range(
start_date: str | None, end_date: str | None, default_start_days_ago: int = 30
) -> tuple[str, str]:
"""
Parse and validate a date range, providing defaults if needed.
Args:
start_date: Start date in YYYY-MM-DD format (optional).
end_date: End date in YYYY-MM-DD format (optional).
default_start_days_ago: Number of days ago for default start date. Defaults to 30.
Returns:
Tuple of (start_date, end_date) as strings in YYYY-MM-DD format.
"""
if not start_date:
start_date = get_default_start_date(default_start_days_ago)
if not end_date:
end_date = get_default_end_date()
return start_date, end_date
@@ -0,0 +1,660 @@
"""
Formatting utilities for Intervals.icu MCP Server
This module contains formatting functions for handling data from the Intervals.icu API.
"""
import json
from datetime import datetime
from typing import Any
class _KeyTracker(dict):
"""A dict wrapper that records which keys are accessed."""
def __init__(self, data: dict[str, Any]) -> None:
super().__init__(data)
self.accessed: set[str] = set()
def get(self, key: str, default: Any = None) -> Any:
self.accessed.add(key)
return super().get(key, default)
def __getitem__(self, key: str) -> Any:
self.accessed.add(key)
return super().__getitem__(key)
def __contains__(self, key: object) -> bool:
if isinstance(key, str):
self.accessed.add(key)
return super().__contains__(key)
def format_activity_summary(activity: dict[str, Any]) -> str:
"""Format an activity into a readable string."""
start_time = activity.get("startTime", activity.get("start_date", "Unknown"))
if isinstance(start_time, str) and len(start_time) > 10:
# Format datetime if it's a full ISO string
try:
dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
start_time = dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
pass
rpe = activity.get("perceived_exertion", None)
if rpe is None:
rpe = activity.get("icu_rpe", "N/A")
if isinstance(rpe, (int, float)):
rpe = f"{rpe}/10"
feel = activity.get("feel", "N/A")
if isinstance(feel, int):
feel = f"{feel}/5"
# Gear (bike, shoes) - ICU activity payloads include the gear ID but not the
# gear name (which lives in /athlete/{id}/gear). The tools.gear module
# resolves the name and injects it as `_resolved_gear_name` before this
# formatter runs. Prefer the resolved name; otherwise fall back to whatever
# the raw payload provides (typically just an ID).
resolved_name = activity.get("_resolved_gear_name")
gear_raw = activity.get("gear")
if resolved_name:
gear_name = resolved_name
if isinstance(gear_raw, dict):
gear_id = gear_raw.get("id", activity.get("gear_id", "N/A"))
else:
gear_id = activity.get("gear_id", "N/A")
elif isinstance(gear_raw, dict):
gear_name = gear_raw.get("name") or gear_raw.get("display_name") or "N/A"
gear_id = gear_raw.get("id", "N/A")
else:
gear_name = activity.get("gear_name", "N/A")
gear_id = activity.get("gear_id", "N/A")
return f"""
Activity: {activity.get("name", "Unnamed")}
ID: {activity.get("id", "N/A")}
Type: {activity.get("type", "Unknown")}
Date: {start_time}
Description: {activity.get("description", "N/A")}
Distance: {activity.get("distance", 0)} meters
Duration: {activity.get("duration", activity.get("elapsed_time", 0))} seconds
Moving Time: {activity.get("moving_time", "N/A")} seconds
Elevation Gain: {activity.get("elevationGain", activity.get("total_elevation_gain", 0))} meters
Elevation Loss: {activity.get("total_elevation_loss", "N/A")} meters
Power Data:
Average Power: {activity.get("avgPower", activity.get("icu_average_watts", activity.get("average_watts", "N/A")))} watts
Weighted Avg Power: {activity.get("icu_weighted_avg_watts", "N/A")} watts
Training Load: {activity.get("trainingLoad", activity.get("icu_training_load", "N/A"))}
FTP: {activity.get("icu_ftp", "N/A")} watts
Kilojoules: {activity.get("icu_joules", "N/A")}
Intensity: {activity.get("icu_intensity", "N/A")}
Power:HR Ratio: {activity.get("icu_power_hr", "N/A")}
Variability Index: {activity.get("icu_variability_index", "N/A")}
Heart Rate Data:
Average Heart Rate: {activity.get("avgHr", activity.get("average_heartrate", "N/A"))} bpm
Max Heart Rate: {activity.get("max_heartrate", "N/A")} bpm
LTHR: {activity.get("lthr", "N/A")} bpm
Resting HR: {activity.get("icu_resting_hr", "N/A")} bpm
Decoupling: {activity.get("decoupling", "N/A")}
Other Metrics:
Cadence: {activity.get("average_cadence", "N/A")} rpm
Calories burned: {activity.get("calories", "N/A")} kcal
Average Speed: {activity.get("average_speed", "N/A")} m/s
Max Speed: {activity.get("max_speed", "N/A")} m/s
Average Stride: {activity.get("average_stride", "N/A")}
L/R Balance: {activity.get("avg_lr_balance", "N/A")}
Weight: {activity.get("icu_weight", "N/A")} kg
RPE: {rpe}
Session RPE: {activity.get("session_rpe", "N/A")}
Feel: {feel}
Environment:
Trainer: {activity.get("trainer", "N/A")}
Average Temp: {activity.get("average_temp", "N/A")}°C
Min Temp: {activity.get("min_temp", "N/A")}°C
Max Temp: {activity.get("max_temp", "N/A")}°C
Avg Wind Speed: {activity.get("average_wind_speed", "N/A")} km/h
Headwind %: {activity.get("headwind_percent", "N/A")}%
Tailwind %: {activity.get("tailwind_percent", "N/A")}%
Training Metrics:
Fitness (CTL): {activity.get("icu_ctl", "N/A")}
Fatigue (ATL): {activity.get("icu_atl", "N/A")}
TRIMP: {activity.get("trimp", "N/A")}
Polarization Index: {activity.get("polarization_index", "N/A")}
Power Load: {activity.get("power_load", "N/A")}
HR Load: {activity.get("hr_load", "N/A")}
Pace Load: {activity.get("pace_load", "N/A")}
Efficiency Factor: {activity.get("icu_efficiency_factor", "N/A")}
Device Info:
Device: {activity.get("device_name", "N/A")}
Power Meter: {activity.get("power_meter", "N/A")}
File Type: {activity.get("file_type", "N/A")}
Gear:
Name: {gear_name}
ID: {gear_id}
"""
def format_workout(workout: dict[str, Any]) -> str:
"""Format a workout into a readable string."""
return f"""
Workout: {workout.get("name", "Unnamed")}
Description: {workout.get("description", "No description")}
Sport: {workout.get("sport", "Unknown")}
Duration: {workout.get("duration", 0)} seconds
TSS: {workout.get("tss", "N/A")}
Intervals: {len(workout.get("intervals", []))}
"""
def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
"""Format training metrics section."""
training_metrics = []
for k, label in [
("ctl", "Fitness (CTL)"),
("atl", "Fatigue (ATL)"),
("rampRate", "Ramp Rate"),
("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"),
]:
if entries.get(k) is not None:
training_metrics.append(f"- {label}: {entries[k]}")
return training_metrics
def _format_sport_info(entries: dict[str, Any]) -> list[str]:
"""Format sport-specific info section."""
sport_info_list = []
if entries.get("sportInfo"):
for sport in entries.get("sportInfo", []):
if isinstance(sport, dict) and sport.get("eftp") is not None:
sport_info_list.append(f"- {sport.get('type')}: eFTP = {sport['eftp']}")
return sport_info_list
def _format_vital_signs(entries: dict[str, Any]) -> list[str]:
"""Format vital signs section."""
vital_signs = []
for k, label, unit in [
("weight", "Weight", "kg"),
("restingHR", "Resting HR", "bpm"),
("hrv", "HRV", ""),
("hrvSDNN", "HRV SDNN", ""),
("avgSleepingHR", "Average Sleeping HR", "bpm"),
("spO2", "SpO2", "%"),
("systolic", "Systolic BP", ""),
("diastolic", "Diastolic BP", ""),
("respiration", "Respiration", "breaths/min"),
("bloodGlucose", "Blood Glucose", "mmol/L"),
("lactate", "Lactate", "mmol/L"),
("vo2max", "VO2 Max", "ml/kg/min"),
("bodyFat", "Body Fat", "%"),
("abdomen", "Abdomen", "cm"),
("baevskySI", "Baevsky Stress Index", ""),
]:
if entries.get(k) is not None:
value = entries[k]
if k == "systolic" and entries.get("diastolic") is not None:
vital_signs.append(
f"- Blood Pressure: {entries['systolic']}/{entries['diastolic']} mmHg"
)
elif k not in ("systolic", "diastolic"):
vital_signs.append(f"- {label}: {value}{(' ' + unit) if unit else ''}")
return vital_signs
def _format_sleep_recovery(entries: dict[str, Any]) -> list[str]:
"""Format sleep and recovery section."""
sleep_lines = []
sleep_hours = None
if entries.get("sleepSecs") is not None:
sleep_hours = f"{entries['sleepSecs'] / 3600:.2f}"
elif entries.get("sleepHours") is not None:
sleep_hours = f"{entries['sleepHours']}"
if sleep_hours is not None:
sleep_lines.append(f" Sleep: {sleep_hours} hours")
if entries.get("sleepQuality") is not None:
quality_value = entries["sleepQuality"]
quality_labels = {1: "Great", 2: "Good", 3: "Average", 4: "Poor"}
quality_text = quality_labels.get(quality_value, str(quality_value))
sleep_lines.append(f" Sleep Quality: {quality_value} ({quality_text})")
if entries.get("sleepScore") is not None:
sleep_lines.append(f" Device Sleep Score: {entries['sleepScore']}/100")
if entries.get("readiness") is not None:
sleep_lines.append(f" Readiness: {entries['readiness']}/10")
return sleep_lines
def _format_menstrual_tracking(entries: dict[str, Any]) -> list[str]:
"""Format menstrual tracking section."""
menstrual_lines = []
if entries.get("menstrualPhase") is not None:
menstrual_lines.append(f" Menstrual Phase: {str(entries['menstrualPhase']).capitalize()}")
if entries.get("menstrualPhasePredicted") is not None:
menstrual_lines.append(
f" Predicted Phase: {str(entries['menstrualPhasePredicted']).capitalize()}"
)
return menstrual_lines
def _format_subjective_feelings(entries: dict[str, Any]) -> list[str]:
"""Format subjective feelings section."""
subjective_lines = []
for k, label in [
("soreness", "Soreness"),
("fatigue", "Fatigue"),
("stress", "Stress"),
("mood", "Mood"),
("motivation", "Motivation"),
("injury", "Injury Level"),
]:
if entries.get(k) is not None:
subjective_lines.append(f" {label}: {entries[k]}/10")
return subjective_lines
def _format_nutrition_hydration(entries: dict[str, Any]) -> list[str]:
"""Format nutrition and hydration section.
Handles both legacy fields (kcalConsumed, hydrationVolume) and the native
macro fields from the Intervals.icu API (carbohydrates, protein,
fatTotal). All fields are rendered conditionally — a null/missing value
hides the corresponding line for backward compatibility with older
wellness records.
"""
nutrition_lines = []
for k, label, unit in [
("kcalConsumed", "Calories Consumed", ""),
("carbohydrates", "Carbohydrates", "g"),
("protein", "Protein", "g"),
("fatTotal", "Fat", "g"),
("hydrationVolume", "Hydration Volume", ""),
]:
if entries.get(k) is not None:
suffix = f" {unit}" if unit else ""
nutrition_lines.append(f"- {label}: {entries[k]}{suffix}")
if entries.get("hydration") is not None:
nutrition_lines.append(f" Hydration Score: {entries['hydration']}/10")
return nutrition_lines
def _format_other_fields(entries: dict[str, Any], known_keys: set[str]) -> list[str]:
"""Format any fields not already handled by the standard formatting sections."""
other_lines = []
for key, value in entries.items():
if key not in known_keys and value is not None:
if isinstance(value, (dict, list)):
other_lines.append(f"- {key}: {json.dumps(value)}")
else:
other_lines.append(f"- {key}: {value}")
return other_lines
def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = False) -> str:
"""Format wellness entry data into a readable string.
Formats various wellness metrics including training metrics, vital signs,
sleep data, menstrual tracking, subjective feelings, nutrition, and activity.
Args:
entries: Dictionary containing wellness data fields such as:
- Training metrics: ctl, atl, rampRate, ctlLoad, atlLoad
- Vital signs: weight, restingHR, hrv, hrvSDNN, avgSleepingHR, spO2,
systolic, diastolic, respiration, bloodGlucose, lactate, vo2max,
bodyFat, abdomen, baevskySI
- Sleep: sleepSecs, sleepHours, sleepQuality, sleepScore, readiness
- Menstrual: menstrualPhase, menstrualPhasePredicted
- Subjective: soreness, fatigue, stress, mood, motivation, injury
- Nutrition: kcalConsumed, carbohydrates, protein, fatTotal, hydrationVolume, hydration
- Activity: steps
- Other: comments, locked, date
include_all_fields: If True, any fields not covered by the standard
sections are appended under an "Other Fields" heading (default False).
Returns:
A formatted string representation of the wellness entry.
"""
if include_all_fields:
entries = _KeyTracker(entries)
# Mark metadata/internal keys so they don't appear in "Other Fields"
entries.get("date")
entries.get("updated")
entries.get("tempWeight")
entries.get("tempRestingHR")
lines = ["Wellness Data:"]
lines.append(f"Date: {entries.get('id', 'N/A')}")
lines.append("")
training_metrics = _format_training_metrics(entries)
if training_metrics:
lines.append("Training Metrics:")
lines.extend(training_metrics)
lines.append("")
sport_info_list = _format_sport_info(entries)
if sport_info_list:
lines.append("Sport-Specific Info:")
lines.extend(sport_info_list)
lines.append("")
vital_signs = _format_vital_signs(entries)
if vital_signs:
lines.append("Vital Signs:")
lines.extend(vital_signs)
lines.append("")
sleep_lines = _format_sleep_recovery(entries)
if sleep_lines:
lines.append("Sleep & Recovery:")
lines.extend(sleep_lines)
lines.append("")
menstrual_lines = _format_menstrual_tracking(entries)
if menstrual_lines:
lines.append("Menstrual Tracking:")
lines.extend(menstrual_lines)
lines.append("")
subjective_lines = _format_subjective_feelings(entries)
if subjective_lines:
lines.append("Subjective Feelings:")
lines.extend(subjective_lines)
lines.append("")
nutrition_lines = _format_nutrition_hydration(entries)
if nutrition_lines:
lines.append("Nutrition & Hydration:")
lines.extend(nutrition_lines)
lines.append("")
if entries.get("steps") is not None:
lines.append("Activity:")
lines.append(f"- Steps: {entries['steps']}")
lines.append("")
if entries.get("comments"):
lines.append(f"Comments: {entries['comments']}")
if "locked" in entries:
lines.append(f"Status: {'Locked' if entries.get('locked') else 'Unlocked'}")
if include_all_fields and isinstance(entries, _KeyTracker):
other_lines = _format_other_fields(entries, entries.accessed)
if other_lines:
lines.append("")
lines.append("Other Fields:")
lines.extend(other_lines)
return "\n".join(lines)
def format_event_summary(event: dict[str, Any]) -> str:
"""Format a basic event summary into a readable string."""
# Update to check for "date" if "start_date_local" is not provided
event_date = event.get("start_date_local", event.get("date", "Unknown"))
event_type = "Workout" if event.get("workout") else "Race" if event.get("race") else "Other"
event_name = event.get("name", "Unnamed")
event_id = event.get("id", "N/A")
event_desc = event.get("description", "No description")
return f"""Date: {event_date}
ID: {event_id}
Type: {event_type}
Name: {event_name}
Description: {event_desc}"""
def format_event_details(event: dict[str, Any]) -> str:
"""Format detailed event information into a readable string."""
event_details = f"""Event Details:
ID: {event.get("id", "N/A")}
Date: {event.get("date", "Unknown")}
Name: {event.get("name", "Unnamed")}
Description: {event.get("description", "No description")}"""
# Check if it's a workout-based event
if "workout" in event and event["workout"]:
workout = event["workout"]
event_details += f"""
Workout Information:
Workout ID: {workout.get("id", "N/A")}
Sport: {workout.get("sport", "Unknown")}
Duration: {workout.get("duration", 0)} seconds
TSS: {workout.get("tss", "N/A")}"""
# Include interval count if available
if "intervals" in workout and isinstance(workout["intervals"], list):
event_details += f"""
Intervals: {len(workout["intervals"])}"""
# Check if it's a race
if event.get("race"):
event_details += f"""
Race Information:
Priority: {event.get("priority", "N/A")}
Result: {event.get("result", "N/A")}"""
# Include calendar information
if "calendar" in event:
cal = event["calendar"]
event_details += f"""
Calendar: {cal.get("name", "N/A")}"""
return event_details
def format_activity_message(message: dict[str, Any]) -> str:
"""Format an activity message/note into a readable string."""
created = message.get("created", "Unknown")
if isinstance(created, str) and len(created) > 10:
try:
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
created = dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
pass
return f"""Author: {message.get("name", "Unknown")}
Date: {created}
Type: {message.get("type", "TEXT")}
Content: {message.get("content", "")}"""
def format_custom_item_details(item: dict[str, Any]) -> str:
"""Format detailed custom item information into a readable string."""
lines = ["Custom Item Details:", ""]
lines.append(f"ID: {item.get('id', 'N/A')}")
lines.append(f"Name: {item.get('name', 'N/A')}")
lines.append(f"Type: {item.get('type', 'N/A')}")
if item.get("description"):
lines.append(f"Description: {item['description']}")
if item.get("visibility"):
lines.append(f"Visibility: {item['visibility']}")
if item.get("index") is not None:
lines.append(f"Index: {item['index']}")
if item.get("hide_script") is not None:
lines.append(f"Hide Script: {item['hide_script']}")
if item.get("content"):
lines.append(f"Content: {json.dumps(item['content'], indent=2)}")
return "\n".join(lines)
def format_intervals(intervals_data: dict[str, Any]) -> str:
"""Format intervals data into a readable string with all available fields.
Args:
intervals_data: The intervals data from the Intervals.icu API
Returns:
A formatted string representation of the intervals data
"""
# Format basic intervals information
result = f"""Intervals Analysis:
ID: {intervals_data.get("id", "N/A")}
Analyzed: {intervals_data.get("analyzed", "N/A")}
"""
# Format individual intervals
if "icu_intervals" in intervals_data and intervals_data["icu_intervals"]:
result += "Individual Intervals:\n\n"
for i, interval in enumerate(intervals_data["icu_intervals"], 1):
result += f"""[{i}] {interval.get("label", f"Interval {i}")} ({interval.get("type", "Unknown")})
Duration: {interval.get("elapsed_time", 0)} seconds (moving: {interval.get("moving_time", 0)} seconds)
Distance: {interval.get("distance", 0)} meters
Start-End Indices: {interval.get("start_index", 0)}-{interval.get("end_index", 0)}
Power Metrics:
Average Power: {interval.get("average_watts", 0)} watts ({interval.get("average_watts_kg", 0)} W/kg)
Max Power: {interval.get("max_watts", 0)} watts ({interval.get("max_watts_kg", 0)} W/kg)
Weighted Avg Power: {interval.get("weighted_average_watts", 0)} watts
Intensity: {interval.get("intensity", 0)}
Training Load: {interval.get("training_load", 0)}
Joules: {interval.get("joules", 0)}
Joules > FTP: {interval.get("joules_above_ftp", 0)}
Power Zone: {interval.get("zone", "N/A")} ({interval.get("zone_min_watts", 0)}-{interval.get("zone_max_watts", 0)} watts)
W' Balance: Start {interval.get("wbal_start", 0)}, End {interval.get("wbal_end", 0)}
L/R Balance: {interval.get("avg_lr_balance", 0)}
Variability: {interval.get("w5s_variability", 0)}
Torque: Avg {interval.get("average_torque", 0)}, Min {interval.get("min_torque", 0)}, Max {interval.get("max_torque", 0)}
Heart Rate & Metabolic:
Heart Rate: Avg {interval.get("average_heartrate", 0)}, Min {interval.get("min_heartrate", 0)}, Max {interval.get("max_heartrate", 0)} bpm
Decoupling: {interval.get("decoupling", 0)}
DFA α1: {interval.get("average_dfa_a1", 0)}
Respiration: {interval.get("average_respiration", 0)} breaths/min
EPOC: {interval.get("average_epoc", 0)}
SmO2: {interval.get("average_smo2", 0)}% / {interval.get("average_smo2_2", 0)}%
THb: {interval.get("average_thb", 0)} / {interval.get("average_thb_2", 0)}
Speed & Cadence:
Speed: Avg {interval.get("average_speed", 0)}, Min {interval.get("min_speed", 0)}, Max {interval.get("max_speed", 0)} m/s
GAP: {interval.get("gap", 0)} m/s
Cadence: Avg {interval.get("average_cadence", 0)}, Min {interval.get("min_cadence", 0)}, Max {interval.get("max_cadence", 0)} rpm
Stride: {interval.get("average_stride", 0)}
Elevation & Environment:
Elevation Gain: {interval.get("total_elevation_gain", 0)} meters
Altitude: Min {interval.get("min_altitude", 0)}, Max {interval.get("max_altitude", 0)} meters
Gradient: {interval.get("average_gradient", 0)}%
Temperature: {interval.get("average_temp", 0)}°C (Weather: {interval.get("average_weather_temp", 0)}°C, Feels like: {interval.get("average_feels_like", 0)}°C)
Wind: Speed {interval.get("average_wind_speed", 0)} km/h, Gust {interval.get("average_wind_gust", 0)} km/h, Direction {interval.get("prevailing_wind_deg", 0)}°
Headwind: {interval.get("headwind_percent", 0)}%, Tailwind: {interval.get("tailwind_percent", 0)}%
"""
# Format interval groups
if "icu_groups" in intervals_data and intervals_data["icu_groups"]:
result += "Interval Groups:\n\n"
for i, group in enumerate(intervals_data["icu_groups"], 1):
result += f"""Group: {group.get("id", f"Group {i}")} (Contains {group.get("count", 0)} intervals)
Duration: {group.get("elapsed_time", 0)} seconds (moving: {group.get("moving_time", 0)} seconds)
Distance: {group.get("distance", 0)} meters
Start-End Indices: {group.get("start_index", 0)}-N/A
Power: Avg {group.get("average_watts", 0)} watts ({group.get("average_watts_kg", 0)} W/kg), Max {group.get("max_watts", 0)} watts
W. Avg Power: {group.get("weighted_average_watts", 0)} watts, Intensity: {group.get("intensity", 0)}
Heart Rate: Avg {group.get("average_heartrate", 0)}, Max {group.get("max_heartrate", 0)} bpm
Speed: Avg {group.get("average_speed", 0)}, Max {group.get("max_speed", 0)} m/s
Cadence: Avg {group.get("average_cadence", 0)}, Max {group.get("max_cadence", 0)} rpm
"""
return result
def _format_duration_label(secs: int) -> str:
"""Format seconds into a concise human-readable label (e.g. 5s, 2m, 1h)."""
if secs < 60:
return f"{secs}s"
if secs < 3600:
mins = secs // 60
remainder = secs % 60
if remainder:
return f"{mins}m{remainder}s"
return f"{mins}m"
hours = secs // 3600
remainder = (secs % 3600) // 60
if remainder:
return f"{hours}h{remainder}m"
return f"{hours}h"
def format_power_curves(
curves: list[dict[str, Any]],
activity_type: str,
include_normalised: bool,
) -> str:
"""Format extracted power curve data into a concise readable string.
Args:
curves: List of extracted curve data dicts with id, label, data_points.
activity_type: The activity type used for the query.
include_normalised: Whether W/kg data is included.
Returns:
A formatted string representation of the power curves.
"""
lines: list[str] = [f"Power Curves ({activity_type}):", ""]
for curve in curves:
label = curve.get("label", curve.get("id", "Unknown"))
start = curve.get("start", "")
end = curve.get("end", "")
date_range = ""
if start and end:
# Trim time portion if present
start_short = start[:10] if len(start) > 10 else start
end_short = end[:10] if len(end) > 10 else end
date_range = f" ({start_short} to {end_short})"
lines.append(f"{label}{date_range}:")
data_points = curve.get("data_points", [])
if not data_points:
lines.append(" No data available for requested durations.")
lines.append("")
continue
for point in data_points:
dur_label = _format_duration_label(point["secs"])
watts = point.get("watts")
aid = point.get("activity_id", "")
parts = [f" {dur_label}: {watts}W"]
if include_normalised and "watts_per_kg" in point:
parts.append(f"{point['watts_per_kg']:.2f}W/kg")
wkg_aid = point.get("wkg_activity_id", "")
if wkg_aid and wkg_aid != aid:
parts.append(f"[{aid}|wkg:{wkg_aid}]")
else:
parts.append(f"[{aid}]")
else:
parts.append(f"[{aid}]")
lines.append(" ".join(parts))
lines.append("")
return "\n".join(lines)
+590
View File
@@ -0,0 +1,590 @@
"""
Type definitions for Intervals.icu MCP Server.
This module contains dataclasses and enums for representing workout data structures
used in the Intervals.icu API, including workout steps, values, and documentation.
Also includes enums for server configuration.
"""
from dataclasses import dataclass
from typing import List, Dict, Optional, Any, Union
from enum import Enum, StrEnum
import json
__all__ = [
"Option",
"WorkoutTarget",
"HrTarget",
"Intensity",
"PaceUnits",
"ValueUnits",
"TransportAliases",
"Value",
"Step",
"SportSettings",
"WorkoutDoc",
]
class Option(Enum):
"""Enumeration of workout option types."""
CATEGORY = "category"
POOL_LENGTH = "pool_length"
POWER = "power"
class WorkoutTarget(Enum):
"""Enumeration of workout target types."""
AUTO = "AUTO"
POWER = "POWER"
HR = "HR"
PACE = "PACE"
class HrTarget(Enum):
"""Enumeration of heart rate target averaging methods."""
LAP = "lap"
INSTANT = "1s"
THREE_SECOND = "3s"
TEN_SECOND = "10s"
THIRTY_SECOND = "30s"
class Intensity(Enum):
"""Enumeration of workout step intensity types."""
ACTIVE = "active"
REST = "rest"
WARMUP = "warmup"
COOLDOWN = "cooldown"
RECOVERY = "recovery"
INTERVAL = "interval"
OTHER = "other"
class PaceUnits(Enum):
"""Enumeration of pace unit types for swimming and running."""
SECS_100M = "SECS_100M"
SECS_100Y = "SECS_100Y"
MINS_KM = "MINS_KM"
MINS_MILE = "MINS_MILE"
SECS_500M = "SECS_500M"
class ValueUnits(Enum):
"""Enumeration of value unit types for workout steps (power, heart rate, pace, cadence)."""
PERCENT_MMP = "%mmp"
PERCENT_HR = "%hr"
PERCENT_LTHR = "%lthr"
PERCENT_PACE = "%pace"
POWER_ZONE = "power_zone"
HR_ZONE = "hr_zone"
PACE_ZONE = "pace_zone"
WATTS = "w"
PERCENT_FTP = "%ftp"
CADENCE = "cadence"
MINS_KM = "MINS_KM"
MINS_MILE = "MINS_MILE"
SECS_100M = "SECS_100M"
SECS_500M = "SECS_500M"
class TransportAliases(StrEnum):
"""Enumeration of supported MCP transport types."""
STDIO = "stdio"
SSE = "sse"
HTTP = "http"
STREAMABLE_HTTP = "streamable-http"
def float_to_str(value: float) -> str:
"""Format the value without decimals if it's a whole number."""
return str(int(value)) if value.is_integer() else str(value)
@dataclass
class Value:
"""Represents a value with units for workout step intensity (power, heart rate, pace, cadence).
Can represent a single value, a range (start-end), or a ramp. Supports various unit types
including percentages, zones, and absolute values.
"""
value: Optional[float] = None
start: Optional[float] = None
end: Optional[float] = None
units: Optional[ValueUnits] = None
target: Optional[HrTarget] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert Value instance to dictionary for JSON serialization."""
data: Dict[str, Any] = {}
if self.value is not None:
data["value"] = self.value
if self.start is not None:
data["start"] = self.start
if self.end is not None:
data["end"] = self.end
if self.units is not None:
data["units"] = self.units.value
if self.target is not None:
data["target"] = self.target.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Value":
"""Create Value instance from dictionary."""
kwargs = {}
if "value" in data:
kwargs["value"] = data["value"]
if "start" in data:
kwargs["start"] = data["start"]
if "end" in data:
kwargs["end"] = data["end"]
if "units" in data:
kwargs["units"] = ValueUnits(data["units"])
if "target" in data:
kwargs["target"] = HrTarget(data["target"])
return cls(**kwargs)
def to_json(self) -> str:
"""Convert Value instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "Value":
"""Create Value instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def _format_value(self, value: float) -> str:
if self.units in [
ValueUnits.PERCENT_HR,
ValueUnits.PERCENT_MMP,
ValueUnits.PERCENT_LTHR,
ValueUnits.PERCENT_PACE,
ValueUnits.PERCENT_FTP,
]:
return f"{float_to_str(value)}%"
if self.units in [ValueUnits.POWER_ZONE, ValueUnits.HR_ZONE, ValueUnits.PACE_ZONE]:
return f"Z{float_to_str(value)}"
if self.units in [ValueUnits.WATTS]:
return f"{float_to_str(value)}W"
if self.units in [ValueUnits.CADENCE]:
return f"{float_to_str(value)}rpm"
return float_to_str(value)
def _format_units(self) -> str:
"""Format units into a human-readable string using dictionary mapping."""
units_map = {
ValueUnits.PERCENT_HR: "HR",
ValueUnits.HR_ZONE: "HR",
ValueUnits.PERCENT_MMP: "MMP",
ValueUnits.PERCENT_LTHR: "LTHR",
ValueUnits.PERCENT_PACE: "Pace",
ValueUnits.PACE_ZONE: "Pace",
ValueUnits.PERCENT_FTP: "ftp",
ValueUnits.POWER_ZONE: "W",
ValueUnits.CADENCE: "Cadence",
}
if self.units is None:
return ""
return units_map.get(self.units, "")
def __str__(self) -> str:
val = ""
if self.start is not None and self.end is not None:
val += f"{self._format_value(self.start)}-{self._format_value(self.end)} "
if self.value is not None:
val += f"{self._format_value(self.value)} "
if self.units is not None:
val += f"{self._format_units()} "
if self.target is not None:
val += f"hr={self.target.value} "
return val.strip()
@dataclass
class Step: # pylint: disable=too-many-instance-attributes
"""Represents a single step in a workout.
A step can be a warmup, cooldown, interval, or repeat block. It can specify
duration, distance, intensity targets (power, heart rate, pace, cadence), and
contain nested steps for repeats.
"""
text: Optional[str] = None
text_locale: Optional[Dict[str, str]] = None
duration: Optional[int] = None
distance: Optional[float] = None
until_lap_press: Optional[bool] = None
reps: Optional[int] = None
warmup: Optional[bool] = None
cooldown: Optional[bool] = None
intensity: Optional[Intensity] = None
steps: Optional[List["Step"]] = None
ramp: Optional[bool] = None
freeride: Optional[bool] = None
maxeffort: Optional[bool] = None
power: Optional[Value] = None
hr: Optional[Value] = None
pace: Optional[Value] = None
cadence: Optional[Value] = None
hidepower: Optional[bool] = None
# these are filled in with actual watts, bpm etc. when resolve=true parameter is supplied to the endpoint
_power: Optional[Value] = None
_hr: Optional[Value] = None
_pace: Optional[Value] = None
_distance: Optional[float] = None
def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches
"""Convert Step instance to dictionary for JSON serialization.
Many branches are required to handle all optional fields of the Step dataclass.
"""
data: Dict[str, Any] = {}
if self.text is not None:
data["text"] = self.text
if self.text_locale is not None:
data["text_locale"] = self.text_locale
if self.duration is not None:
data["duration"] = self.duration
if self.distance is not None:
data["distance"] = self.distance
if self.until_lap_press is not None:
data["until_lap_press"] = self.until_lap_press
if self.reps is not None:
data["reps"] = self.reps
if self.warmup is not None:
data["warmup"] = self.warmup
if self.cooldown is not None:
data["cooldown"] = self.cooldown
if self.intensity is not None:
data["intensity"] = self.intensity.value
if self.steps is not None:
data["steps"] = [step.to_dict() for step in self.steps]
if self.ramp is not None:
data["ramp"] = self.ramp
if self.freeride is not None:
data["freeride"] = self.freeride
if self.maxeffort is not None:
data["maxeffort"] = self.maxeffort
if self.power is not None:
data["power"] = self.power.to_dict()
if self.hr is not None:
data["hr"] = self.hr.to_dict()
if self.pace is not None:
data["pace"] = self.pace.to_dict()
if self.cadence is not None:
data["cadence"] = self.cadence.to_dict()
if self.hidepower is not None:
data["hidepower"] = self.hidepower
if self._power is not None:
data["_power"] = self._power.to_dict()
if self._hr is not None:
data["_hr"] = self._hr.to_dict()
if self._pace is not None:
data["_pace"] = self._pace.to_dict()
if self._distance is not None:
data["_distance"] = self._distance
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Step": # pylint: disable=too-many-branches
"""Create Step instance from dictionary.
Many branches are required to handle all optional fields of the Step dataclass.
"""
kwargs = {}
if "text" in data:
kwargs["text"] = data["text"]
if "text_locale" in data:
kwargs["text_locale"] = data["text_locale"]
if "duration" in data:
kwargs["duration"] = data["duration"]
if "distance" in data:
kwargs["distance"] = data["distance"]
if "until_lap_press" in data:
kwargs["until_lap_press"] = data["until_lap_press"]
if "reps" in data:
kwargs["reps"] = data["reps"]
if "warmup" in data:
kwargs["warmup"] = data["warmup"]
if "cooldown" in data:
kwargs["cooldown"] = data["cooldown"]
if "intensity" in data:
kwargs["intensity"] = Intensity(data["intensity"])
if "steps" in data:
kwargs["steps"] = [cls.from_dict(step) for step in data["steps"]]
if "ramp" in data:
kwargs["ramp"] = data["ramp"]
if "freeride" in data:
kwargs["freeride"] = data["freeride"]
if "maxeffort" in data:
kwargs["maxeffort"] = data["maxeffort"]
if "power" in data:
kwargs["power"] = Value.from_dict(data["power"])
if "hr" in data:
kwargs["hr"] = Value.from_dict(data["hr"])
if "pace" in data:
kwargs["pace"] = Value.from_dict(data["pace"])
if "cadence" in data:
kwargs["cadence"] = Value.from_dict(data["cadence"])
if "hidepower" in data:
kwargs["hidepower"] = data["hidepower"]
if "_power" in data:
kwargs["_power"] = Value.from_dict(data["_power"])
if "_hr" in data:
kwargs["_hr"] = Value.from_dict(data["_hr"])
if "_pace" in data:
kwargs["_pace"] = Value.from_dict(data["_pace"])
if "_distance" in data:
kwargs["_distance"] = data["_distance"]
return cls(**kwargs)
def to_json(self) -> str:
"""Convert Step instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "Step":
"""Create Step instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def _format_duration(self) -> str:
"""Format duration into a human-readable string."""
if self.duration is None:
return ""
remaining_duration = self.duration
val = ""
if remaining_duration > 3600:
val += f"{remaining_duration // 3600}h"
remaining_duration %= 3600
if remaining_duration > 100 or remaining_duration == 60:
val += f"{remaining_duration // 60}m"
remaining_duration %= 60
if remaining_duration > 0:
val += f"{remaining_duration}s"
return val
def _format_distance(self) -> str:
"""Format distance into a human-readable string."""
if self.distance is None:
return ""
if self.distance < 1000:
return f"{float_to_str(self.distance)}mtr"
return f"{float_to_str(self.distance / 1000)}km"
def __str__(self) -> str:
"""Convert Step to string representation."""
return self._to_str()
def _to_str(self, nested: bool = False) -> str: # pylint: disable=too-many-branches
"""Convert Step to string representation.
Many branches are required to format all optional fields and handle different step types.
"""
val = ""
if self.reps is not None:
if nested:
raise ValueError("Nested steps not supported")
val += f"\n{self.reps}x "
else:
if not nested and self.warmup:
val += "\nWarmup\n"
if not nested and self.cooldown:
val += "\nCooldown\n"
val += ""
if self.duration is not None:
val += f"- {self._format_duration()} "
elif self.distance is not None:
val += f"- {self._format_distance()} "
if self.freeride:
val += "freeride "
if self.maxeffort:
val += "maxeffort "
if self.ramp:
val += "ramp "
if self.hidepower:
val += "hidepower "
if self.intensity is not None:
val += f"intensity={self.intensity.value} "
if self.power is not None:
val += f"{self.power} "
if self.hr is not None:
val += f"{self.hr} "
if self.pace is not None:
val += f"{self.pace} "
if self.cadence is not None:
val += f"{self.cadence} "
if self.text is not None:
val += f"{self.text} "
if self.reps is not None and self.steps is not None:
for step in self.steps:
# Using _to_str instead of __str__ because we need the nested=True arg;
# __str__ can't accept extra parameters.
val += "\n" + step._to_str(nested=True) # pylint: disable=protected-access
val += "\n"
elif not nested and (self.warmup or self.cooldown):
val += "\n"
return val
@dataclass
class SportSettings:
"""Represents sport-specific settings for a workout.
Currently empty, but can be extended with sport-specific configuration
as needed by the Intervals.icu API.
"""
def to_dict(self) -> Dict[str, Any]:
"""Convert SportSettings instance to dictionary for JSON serialization."""
return {}
@classmethod
def from_dict(cls, _data: Dict[str, Any]) -> "SportSettings":
"""Create SportSettings instance from dictionary."""
return cls()
def to_json(self) -> str:
"""Convert SportSettings instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "SportSettings":
"""Create SportSettings instance from JSON string."""
return cls.from_dict(json.loads(json_str))
@dataclass
class WorkoutDoc: # pylint: disable=too-many-instance-attributes
"""Represents a complete workout document with description, steps, and settings.
This is the main structure used to define workouts for the Intervals.icu API,
containing workout metadata, step definitions, and sport-specific settings.
Many instance attributes are required to match the Intervals.icu API schema exactly.
"""
description: Optional[str] = None
description_locale: Optional[Dict[str, str]] = None
duration: Optional[int] = None
distance: Optional[float] = None
ftp: Optional[int] = None
lthr: Optional[int] = None
threshold_pace: Optional[float] = None # meters/sec
pace_units: Optional[PaceUnits] = None
sport_settings: Optional[SportSettings] = None
category: Optional[str] = None
target: Optional[WorkoutTarget] = None
steps: Optional[List[Step]] = None
zone_times: Optional[List[Union[int, Any]]] = (
None # sometimes array of ints otherwise array of objects
)
options: Optional[Dict[str, str]] = None
locales: Optional[List[str]] = None
def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches
"""Convert WorkoutDoc instance to dictionary for JSON serialization.
Many branches are required to handle all optional fields of the WorkoutDoc dataclass.
"""
data: Dict[str, Any] = {}
if self.description is not None:
data["description"] = self.description
if self.description_locale is not None:
data["description_locale"] = self.description_locale
if self.duration is not None:
data["duration"] = self.duration
if self.distance is not None:
data["distance"] = self.distance
if self.ftp is not None:
data["ftp"] = self.ftp
if self.lthr is not None:
data["lthr"] = self.lthr
if self.threshold_pace is not None:
data["threshold_pace"] = self.threshold_pace
if self.pace_units is not None:
data["pace_units"] = self.pace_units.value
if self.sport_settings is not None:
data["sportSettings"] = self.sport_settings.to_dict() # API uses camelCase
if self.category is not None:
data["category"] = self.category
if self.target is not None:
data["target"] = self.target.value
if self.steps is not None:
data["steps"] = [step.to_dict() for step in self.steps]
if self.zone_times is not None:
data["zoneTimes"] = self.zone_times # API uses camelCase
if self.options is not None:
data["options"] = self.options
if self.locales is not None:
data["locales"] = self.locales
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "WorkoutDoc": # pylint: disable=too-many-branches
"""Create WorkoutDoc instance from dictionary.
Many branches are required to handle all optional fields of the WorkoutDoc dataclass.
"""
kwargs = {}
if "description" in data:
kwargs["description"] = data["description"]
if "description_locale" in data:
kwargs["description_locale"] = data["description_locale"]
if "duration" in data:
kwargs["duration"] = data["duration"]
if "distance" in data:
kwargs["distance"] = data["distance"]
if "ftp" in data:
kwargs["ftp"] = data["ftp"]
if "lthr" in data:
kwargs["lthr"] = data["lthr"]
if "threshold_pace" in data:
kwargs["threshold_pace"] = data["threshold_pace"]
if "pace_units" in data:
kwargs["pace_units"] = PaceUnits(data["pace_units"])
if "sportSettings" in data: # API uses camelCase
kwargs["sport_settings"] = SportSettings.from_dict(data["sportSettings"])
if "category" in data:
kwargs["category"] = data["category"]
if "target" in data:
kwargs["target"] = WorkoutTarget(data["target"])
if "steps" in data:
kwargs["steps"] = [Step.from_dict(step) for step in data["steps"]]
if "zoneTimes" in data: # API uses camelCase
kwargs["zone_times"] = data["zoneTimes"]
if "options" in data:
kwargs["options"] = data["options"]
if "locales" in data:
kwargs["locales"] = data["locales"]
return cls(**kwargs)
def to_json(self) -> str:
"""Convert WorkoutDoc instance to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "WorkoutDoc":
"""Create WorkoutDoc instance from JSON string."""
return cls.from_dict(json.loads(json_str))
def __str__(self) -> str:
val = ""
if self.description is not None:
val += f"{self.description}\n"
if self.steps is not None:
for step in self.steps:
val += step.__str__() + "\n"
return val
@@ -0,0 +1,118 @@
"""
Validation utilities for Intervals.icu MCP Server
This module contains validation functions for input parameters.
"""
import re
from datetime import datetime
from intervals_mcp_server.utils.dates import parse_date_range
def validate_athlete_id(athlete_id: str) -> None:
"""Validate that an athlete ID is in the correct format.
Empty strings are allowed (meaning no default athlete ID is set).
Non-empty athlete IDs must be all digits or start with 'i' followed by digits.
Args:
athlete_id: The athlete ID to validate.
Raises:
ValueError: If the athlete ID is not in the correct format.
"""
if athlete_id and not re.fullmatch(r"i?\d+", athlete_id):
raise ValueError(
"ATHLETE_ID must be all digits (e.g. 123456) or start with 'i' followed by digits (e.g. i123456)"
)
def validate_date(date_str: str) -> str:
"""Validate that a date string is in YYYY-MM-DD format.
Args:
date_str: The date string to validate.
Returns:
The validated date string if valid.
Raises:
ValueError: If the date string is not in YYYY-MM-DD format.
"""
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError as exc:
raise ValueError("Invalid date format. Please use YYYY-MM-DD.") from exc
def resolve_athlete_id(
athlete_id: str | None, default_athlete_id: str = ""
) -> tuple[str, str | None]:
"""Resolve athlete ID from parameter or default, with error message if missing.
Args:
athlete_id: Optional athlete ID parameter.
default_athlete_id: Default athlete ID to use if athlete_id is None.
Returns:
Tuple of (athlete_id_to_use, error_message).
athlete_id_to_use will be empty string if not found.
error_message will be None if athlete_id is resolved successfully.
"""
athlete_id_to_use = athlete_id if athlete_id is not None else default_athlete_id
if not athlete_id_to_use:
return (
"",
"Error: No athlete ID provided and no default ATHLETE_ID found in environment variables.",
)
return athlete_id_to_use, None
def resolve_activity_type(name: str | None, activity_type: str | None = None) -> str:
"""Determine the activity type based on the name and provided value.
If an explicit *activity_type* is given it is returned as-is. Otherwise the
*name* is searched for common keywords to infer the type, defaulting to
``"Ride"`` when no match is found.
Args:
name: An optional activity/event name to infer the type from.
activity_type: An explicitly provided activity type.
Returns:
The resolved activity type string.
"""
if activity_type:
return activity_type
name_lower = name.lower() if name else ""
mapping = [
("Ride", ["bike", "cycle", "cycling", "ride"]),
("Run", ["run", "running", "jog", "jogging"]),
("Swim", ["swim", "swimming", "pool"]),
("Walk", ["walk", "walking", "hike", "hiking"]),
("Row", ["row", "rowing"]),
]
for workout, keywords in mapping:
if any(keyword in name_lower for keyword in keywords):
return workout
return "Ride" # Default
def resolve_date_params(
start_date: str | None,
end_date: str | None,
default_start_days_ago: int = 30,
) -> tuple[str, str]:
"""Resolve start and end date parameters with defaults.
Args:
start_date: Optional start date in YYYY-MM-DD format.
end_date: Optional end date in YYYY-MM-DD format.
default_start_days_ago: Number of days ago for default start date. Defaults to 30.
Returns:
Tuple of (start_date, end_date) as strings in YYYY-MM-DD format.
"""
return parse_date_range(start_date, end_date, default_start_days_ago)
View File
+55
View File
@@ -0,0 +1,55 @@
{
"id": "2025-05-24",
"ctl": 70.87253,
"atl": 91.97159,
"rampRate": 6.997368,
"ctlLoad": 299,
"atlLoad": 299,
"sportInfo": [
{
"type": "Ride",
"eftp": 282.85992
},
{
"type": "Run",
"eftp": null
}
],
"updated": "2025-05-25T08:10:00.827+00:00",
"weight": 78,
"restingHR": 50,
"hrv": null,
"hrvSDNN": null,
"menstrualPhase": null,
"menstrualPhasePredicted": null,
"kcalConsumed": null,
"carbohydrates": null,
"protein": null,
"fatTotal": null,
"sleepSecs": null,
"sleepScore": null,
"sleepQuality": null,
"avgSleepingHR": null,
"soreness": null,
"fatigue": null,
"stress": null,
"mood": null,
"motivation": null,
"injury": null,
"spO2": null,
"systolic": null,
"diastolic": null,
"hydration": null,
"hydrationVolume": null,
"readiness": null,
"baevskySI": null,
"bloodGlucose": null,
"lactate": null,
"bodyFat": null,
"abdomen": null,
"vo2max": null,
"comments": null,
"steps": 2303,
"respiration": null,
"locked": null
}
@@ -0,0 +1,21 @@
Wellness Data:
Date: 2025-05-24
Training Metrics:
- Fitness (CTL): 70.87253
- Fatigue (ATL): 91.97159
- Ramp Rate: 6.997368
- CTL Load: 299
- ATL Load: 299
Sport-Specific Info:
- Ride: eFTP = 282.85992
Vital Signs:
- Weight: 78 kg
- Resting HR: 50 bpm
Activity:
- Steps: 2303
Status: Unlocked
+87
View File
@@ -0,0 +1,87 @@
"""
Sample data for testing Intervals.icu MCP server functions.
This module contains test data structures used across the test suite.
"""
INTERVALS_DATA = {
"id": "i1",
"analyzed": True,
"icu_intervals": [
{
"type": "work",
"label": "Rep 1",
"elapsed_time": 60,
"moving_time": 60,
"distance": 100,
"average_watts": 200,
"max_watts": 300,
"average_watts_kg": 3.0,
"max_watts_kg": 5.0,
"weighted_average_watts": 220,
"intensity": 0.8,
"training_load": 10,
"average_heartrate": 150,
"max_heartrate": 160,
"average_cadence": 90,
"max_cadence": 100,
"average_speed": 6,
"max_speed": 8,
}
],
}
POWER_CURVES_DATA = {
"list": [
{
"id": "s0",
"label": "This season",
"start_date_local": "2025-09-29T00:00:00",
"end_date_local": "2026-03-14T00:00:00",
"days": 167,
"weight": 75.0,
"secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600],
"values": [900, 850, 820, 800, 780, 650, 550, 450, 380, 320, 280, 260, 245, 210],
"activity_id": [
"i100", "i100", "i100", "i100", "i100",
"i101", "i101", "i101", "i102",
"i103", "i104", "i105", "i106", "i107",
],
"watts_per_kg": [
12.0, 11.33, 10.93, 10.67, 10.4,
8.67, 7.33, 6.0, 5.07,
4.27, 3.73, 3.47, 3.27, 2.8,
],
"wkg_activity_id": [
"i100", "i100", "i100", "i100", "i100",
"i101", "i101", "i101", "i102",
"i103", "i104", "i105", "i106", "i107",
],
},
{
"id": "s1",
"label": "Last season",
"start_date_local": "2024-09-29T00:00:00",
"end_date_local": "2025-09-28T00:00:00",
"days": 365,
"weight": 76.0,
"secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600],
"values": [870, 830, 800, 770, 750, 630, 520, 430, 360, 300, 265, 250, 235, 200],
"activity_id": [
"i200", "i200", "i200", "i200", "i200",
"i201", "i201", "i201", "i202",
"i203", "i204", "i205", "i206", "i207",
],
"watts_per_kg": [
11.45, 10.92, 10.53, 10.13, 9.87,
8.29, 6.84, 5.66, 4.74,
3.95, 3.49, 3.29, 3.09, 2.63,
],
"wkg_activity_id": [
"i200", "i200", "i200", "i200", "i200",
"i201", "i201", "i201", "i202",
"i203", "i204", "i205", "i206", "i207",
],
},
]
}
+239
View File
@@ -0,0 +1,239 @@
"""
Unit tests for formatting utilities in intervals_mcp_server.utils.formatting.
These tests verify that the formatting functions produce expected output strings for activities, workouts, wellness entries, events, and intervals.
"""
import json
from intervals_mcp_server.utils.formatting import (
format_activity_summary,
format_workout,
format_wellness_entry,
format_event_summary,
format_event_details,
format_intervals,
format_power_curves,
)
from tests.sample_data import INTERVALS_DATA
def test_format_activity_summary():
"""
Test that format_activity_summary returns a string containing the activity name and ID.
"""
data = {
"name": "Morning Ride",
"id": 1,
"type": "Ride",
"startTime": "2024-01-01T08:00:00Z",
"distance": 1000,
"duration": 3600,
}
result = format_activity_summary(data)
assert "Activity: Morning Ride" in result
assert "ID: 1" in result
def test_format_workout():
"""
Test that format_workout returns a string containing the workout name and interval count.
"""
workout = {
"name": "Workout1",
"description": "desc",
"sport": "Ride",
"duration": 3600,
"tss": 50,
"intervals": [1, 2, 3],
}
result = format_workout(workout)
assert "Workout: Workout1" in result
assert "Intervals: 3" in result
def test_format_wellness_entry():
"""
Test that format_wellness_entry returns a string containing the date and fitness (CTL).
"""
with open("tests/ressources/wellness_entry.json", "r", encoding="utf-8") as f:
entry = json.load(f)
result = format_wellness_entry(entry)
with open("tests/ressources/wellness_entry_formatted.txt", "r", encoding="utf-8") as f:
expected_result = f.read()
assert result == expected_result
def test_format_wellness_entry_include_all_fields():
"""
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
"""
entry = {
"id": "2024-06-01",
"ctl": 80,
"weight": 75,
"customField1": "hello",
"customField2": 42,
"updated": "2024-06-01T10:00:00Z",
}
result = format_wellness_entry(entry, include_all_fields=True)
assert "Date: 2024-06-01" in result
assert "Fitness (CTL): 80" in result
assert "Weight: 75 kg" in result
assert "Other Fields:" in result
assert "customField1: hello" in result
assert "customField2: 42" in result
# "updated" is a known built-in field, should not appear in Other Fields
assert "updated:" not in result
def test_format_wellness_entry_no_extra_fields_by_default():
"""
Test that format_wellness_entry without include_all_fields does not include additional fields.
"""
entry = {
"id": "2024-06-01",
"ctl": 80,
"customField1": "hello",
}
result = format_wellness_entry(entry)
assert "Other Fields:" not in result
assert "customField1" not in result
def test_format_wellness_entry_macros_populated():
"""
Test that format_wellness_entry renders native nutrition macros
(carbohydrates, protein, fatTotal) in grams when present.
"""
entry = {
"id": "2026-04-08",
"carbohydrates": 310,
"protein": 145,
"fatTotal": 72,
}
result = format_wellness_entry(entry)
assert "Nutrition & Hydration:" in result
assert "- Carbohydrates: 310 g" in result
assert "- Protein: 145 g" in result
assert "- Fat: 72 g" in result
def test_format_wellness_entry_macros_null_hidden():
"""
Test that format_wellness_entry hides macro lines when the fields are null,
preserving backward compatibility with older wellness records.
"""
entry = {
"id": "2026-04-08",
"ctl": 80,
"carbohydrates": None,
"protein": None,
"fatTotal": None,
}
result = format_wellness_entry(entry)
assert "Carbohydrates" not in result
assert "Protein" not in result
# "Fat" could legitimately appear inside e.g. "Body Fat" elsewhere, so
# anchor the negative assertion on the line-prefix form we would emit.
assert "- Fat:" not in result
def test_format_event_summary():
"""
Test that format_event_summary returns a string containing the event date and type.
"""
event = {
"start_date_local": "2024-01-01",
"id": "e1",
"name": "Event1",
"description": "desc",
"race": True,
}
summary = format_event_summary(event)
assert "Date: 2024-01-01" in summary
assert "Type: Race" in summary
def test_format_event_details():
"""
Test that format_event_details returns a string containing event and workout details.
"""
event = {
"id": "e1",
"date": "2024-01-01",
"name": "Event1",
"description": "desc",
"workout": {
"id": "w1",
"sport": "Ride",
"duration": 3600,
"tss": 50,
"intervals": [1, 2],
},
"race": True,
"priority": "A",
"result": "1st",
"calendar": {"name": "Main"},
}
details = format_event_details(event)
assert "Event Details:" in details
assert "Workout Information:" in details
def test_format_intervals():
"""
Test that format_intervals returns a string containing interval analysis and the interval label.
"""
result = format_intervals(INTERVALS_DATA)
assert "Intervals Analysis:" in result
assert "Rep 1" in result
def test_format_power_curves():
"""
Test that format_power_curves returns a concise string with curve labels,
power values, W/kg values, and activity IDs.
"""
curves = [
{
"id": "s0",
"label": "This season",
"start": "2025-09-29T00:00:00",
"end": "2026-03-14T00:00:00",
"data_points": [
{"secs": 5, "watts": 780, "activity_id": "i100", "watts_per_kg": 10.4, "wkg_activity_id": "i100"},
{"secs": 60, "watts": 380, "activity_id": "i102", "watts_per_kg": 5.07, "wkg_activity_id": "i102"},
{"secs": 3600, "watts": 210, "activity_id": "i107", "watts_per_kg": 2.8, "wkg_activity_id": "i107"},
],
},
]
result = format_power_curves(curves, "Ride", include_normalised=True)
assert "Power Curves (Ride):" in result
assert "This season" in result
assert "5s: 780W" in result
assert "10.40W/kg" in result
assert "1m: 380W" in result
assert "1h: 210W" in result
assert "i100" in result
assert "i107" in result
def test_format_power_curves_without_normalised():
"""
Test that format_power_curves without normalised data does not include W/kg.
"""
curves = [
{
"id": "s0",
"label": "This season",
"start": "",
"end": "",
"data_points": [
{"secs": 5, "watts": 780, "activity_id": "i100"},
],
},
]
result = format_power_curves(curves, "Ride", include_normalised=False)
assert "780W" in result
assert "W/kg" not in result
+101
View File
@@ -0,0 +1,101 @@
"""
Unit tests for the make_intervals_request function in intervals_mcp_server.server.
These tests focus on error handling, particularly the scenario where the API returns invalid JSON.
Mock classes are used to simulate httpx responses and client behavior.
"""
import asyncio
import logging
import os
import pathlib
import sys
from json import JSONDecodeError
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
os.environ.setdefault("API_KEY", "test")
os.environ.setdefault("ATHLETE_ID", "i1")
from intervals_mcp_server import server # pylint: disable=wrong-import-position
from intervals_mcp_server.api import client as api_client # pylint: disable=wrong-import-position
from intervals_mcp_server.config import Config # pylint: disable=wrong-import-position
class MockBadJSONResponse:
"""
Simulates an httpx response object that returns invalid JSON content.
Used to test error handling for JSONDecodeError in make_intervals_request.
"""
def __init__(self):
self.content = b"bad"
self.status_code = 200
def raise_for_status(self):
"""Mock raise_for_status that does nothing."""
return None
def json(self):
"""Raise JSONDecodeError to simulate invalid JSON."""
raise JSONDecodeError("Expecting value", "bad", 0)
class MockAsyncClient:
"""
Simulates an httpx.AsyncClient for use in monkeypatching.
Always returns a MockBadJSONResponse from get().
"""
def __init__(self, *_args, **_kwargs):
# Accept any arguments to match httpx.AsyncClient's interface
self.is_closed = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
pass
async def get(self, _url, **_kwargs):
"""Mock get method that returns MockBadJSONResponse."""
return MockBadJSONResponse()
async def request(self, *_args, **_kwargs):
"""Mock request method that returns MockBadJSONResponse."""
return MockBadJSONResponse()
async def aclose(self):
"""Simulate closing the AsyncClient."""
self.is_closed = True
def test_make_intervals_request_bad_json(monkeypatch, caplog):
"""
Test that make_intervals_request returns an error dict when the response contains invalid JSON.
Ensures proper logging and error message content.
"""
monkeypatch.setenv("API_KEY", "test")
monkeypatch.setenv("ATHLETE_ID", "i1")
# Reset the singleton so config picks up the monkeypatched env vars
monkeypatch.setattr("intervals_mcp_server.config._config_instance", None)
monkeypatch.setattr(server, "httpx_client", MockAsyncClient())
monkeypatch.setattr(
api_client,
"get_config",
lambda: Config(
api_key="test",
athlete_id="i1",
intervals_api_base_url="https://intervals.icu/api/v1",
user_agent="test-agent",
),
)
# Ensure the config singleton has an API key, regardless of test execution order
from intervals_mcp_server.config import get_config # pylint: disable=import-outside-toplevel
monkeypatch.setattr(get_config(), "api_key", "test")
with caplog.at_level(logging.ERROR):
result = asyncio.run(server.make_intervals_request("/bad"))
assert result["error"] is True
assert "Invalid JSON in response" in result["message"]
+951
View File
@@ -0,0 +1,951 @@
"""
Unit tests for the main MCP server tool functions in intervals_mcp_server.server.
These tests use monkeypatching to mock API responses and verify the formatting and output of each tool function:
- 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
- get_wellness_data
The tests ensure that the server's public API returns expected strings and handles data correctly.
"""
import asyncio
import os
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
os.environ.setdefault("API_KEY", "test")
os.environ.setdefault("ATHLETE_ID", "i1")
from intervals_mcp_server.server import ( # pylint: disable=wrong-import-position
add_activity_message,
get_activities,
get_activity_details,
get_activity_intervals,
get_activity_messages,
get_activity_streams,
add_or_update_event,
get_athlete_power_curves,
get_event_by_id,
get_events,
get_gear_list,
get_wellness_data,
get_custom_items,
get_custom_item_by_id,
create_custom_item,
update_custom_item,
delete_custom_item,
)
from intervals_mcp_server.tools import gear as gear_module # pylint: disable=wrong-import-position
from tests.sample_data import INTERVALS_DATA, POWER_CURVES_DATA # pylint: disable=wrong-import-position
def _reset_gear_cache():
"""Helper to clear the module-level gear cache between tests."""
gear_module._GEAR_RAW_CACHE.clear() # pylint: disable=protected-access
def test_get_activities(monkeypatch):
"""
Test get_activities returns a formatted string containing activity details when given a sample activity.
"""
sample = {
"name": "Morning Ride",
"id": 123,
"type": "Ride",
"startTime": "2024-01-01T08:00:00Z",
"distance": 1000,
"duration": 3600,
}
async def fake_request(*_args, **_kwargs):
return [sample]
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activities(athlete_id="1", limit=1, include_unnamed=True))
assert "Morning Ride" in result
assert "Activities:" in result
def test_get_activity_details(monkeypatch):
"""
Test get_activity_details returns a formatted string with the activity name and details.
"""
sample = {
"name": "Morning Ride",
"id": 123,
"type": "Ride",
"startTime": "2024-01-01T08:00:00Z",
"distance": 1000,
"duration": 3600,
}
async def fake_request(*_args, **_kwargs):
return sample
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_details(123))
assert "Activity: Morning Ride" in result
def test_get_events(monkeypatch):
"""
Test get_events returns a formatted string containing event details when given a sample event.
"""
event = {
"date": "2024-01-01",
"id": "e1",
"name": "Test Event",
"description": "desc",
"race": True,
}
async def fake_request(*_args, **_kwargs):
return [event]
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
result = asyncio.run(get_events(athlete_id="1", start_date="2024-01-01", end_date="2024-01-02"))
assert "Test Event" in result
assert "Events:" in result
def test_get_event_by_id(monkeypatch):
"""
Test get_event_by_id returns a formatted string with event details for a given event ID.
"""
event = {
"id": "e1",
"date": "2024-01-01",
"name": "Test Event",
"description": "desc",
"race": True,
}
async def fake_request(*_args, **_kwargs):
return event
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
result = asyncio.run(get_event_by_id("e1", athlete_id="1"))
assert "Event Details:" in result
assert "Test Event" in result
def test_get_wellness_data(monkeypatch):
"""
Test get_wellness_data returns a formatted string containing wellness data for a given athlete.
"""
wellness = {
"2024-01-01": {
"id": "2024-01-01",
"ctl": 75,
"sleepSecs": 28800,
}
}
async def fake_request(*_args, **_kwargs):
return wellness
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1"))
assert "Wellness Data:" in result
assert "2024-01-01" in result
def test_get_wellness_data_renders_macros(monkeypatch):
"""
Integration test: native nutrition macros (carbohydrates, protein,
fatTotal) flow from the API response through get_wellness_data into the
formatted output.
"""
wellness = [
{
"id": "2026-04-08",
"carbohydrates": 310,
"protein": 145,
"fatTotal": 72,
}
]
async def fake_request(*_args, **_kwargs):
return wellness
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1"))
assert "Wellness Data:" in result
assert "2026-04-08" in result
assert "Nutrition & Hydration:" in result
assert "- Carbohydrates: 310 g" in result
assert "- Protein: 145 g" in result
assert "- Fat: 72 g" in result
def test_get_wellness_data_include_all_fields(monkeypatch):
"""
Test get_wellness_data with include_all_fields=True returns a formatted string including additional fields.
"""
wellness = [
{
"id": "2024-01-01",
"ctl": 75,
"sleepSecs": 28800,
"customField": "custom_value",
}
]
async def fake_request(*_args, **_kwargs):
return wellness
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
result = asyncio.run(get_wellness_data(athlete_id="1", include_all_fields=True))
assert "Wellness Data:" in result
assert "2024-01-01" in result
assert "Fitness (CTL): 75" in result
assert "Other Fields:" in result
assert "customField: custom_value" in result
def test_get_activity_intervals(monkeypatch):
"""
Test get_activity_intervals returns a formatted string with interval analysis for a given activity.
"""
async def fake_request(*_args, **_kwargs):
return INTERVALS_DATA
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_intervals("123"))
assert "Intervals Analysis:" in result
assert "Rep 1" in result
def test_get_activity_streams(monkeypatch):
"""
Test get_activity_streams returns a formatted string with stream data for a given activity.
"""
sample_streams = [
{
"type": "time",
"name": "time",
"data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"data2": [],
"valueType": "time_units",
"valueTypeIsArray": False,
"anomalies": None,
"custom": False,
},
{
"type": "watts",
"name": "watts",
"data": [150, 155, 160, 165, 170, 175, 180, 185, 190, 195, 200],
"data2": [],
"valueType": "power_units",
"valueTypeIsArray": False,
"anomalies": None,
"custom": False,
},
{
"type": "heartrate",
"name": "heartrate",
"data": [120, 125, 130, 135, 140, 145, 150, 155, 160, 165, 170],
"data2": [],
"valueType": "hr_units",
"valueTypeIsArray": False,
"anomalies": None,
"custom": False,
},
]
async def fake_request(*_args, **_kwargs):
return sample_streams
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_streams("i107537962"))
assert "Activity Streams" in result
assert "time" in result
assert "watts" in result
assert "heartrate" in result
assert "Data Points: 11" in result
def test_add_or_update_event(monkeypatch):
"""
Test add_or_update_event successfully posts an event and returns the response data.
"""
expected_response = {
"id": "e123",
"start_date_local": "2024-01-15T00:00:00",
"category": "WORKOUT",
"name": "Test Workout",
"type": "Ride",
}
async def fake_post_request(*_args, **_kwargs):
return expected_response
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_post_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.events.make_intervals_request", fake_post_request
)
result = asyncio.run(
add_or_update_event(
athlete_id="i1", start_date="2024-01-15", name="Test Workout", workout_type="Ride"
)
)
assert "Successfully created event id:" in result
assert "e123" in result
def test_get_activity_messages(monkeypatch):
"""Test get_activity_messages returns formatted messages for an activity."""
sample_messages = [
{
"id": 1,
"name": "Niko",
"created": "2024-06-15T10:30:00Z",
"type": "NOTE",
"content": "Legs felt heavy today",
},
{
"id": 2,
"name": "Coach",
"created": "2024-06-15T11:00:00Z",
"type": "TEXT",
"content": "Good effort despite that!",
},
]
async def fake_request(*_args, **_kwargs):
return sample_messages
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_messages(activity_id="i123"))
assert "Legs felt heavy today" in result
assert "Good effort despite that!" in result
assert "Niko" in result
assert "Coach" in result
def test_get_activity_messages_error(monkeypatch):
"""Test get_activity_messages handles API errors gracefully."""
async def fake_request(*_args, **_kwargs):
return {"error": True, "message": "Activity not found"}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_messages(activity_id="i999"))
assert "Error fetching activity messages" in result
assert "Activity not found" in result
def test_get_activity_messages_empty(monkeypatch):
"""Test get_activity_messages returns appropriate message when no messages exist."""
async def fake_request(*_args, **_kwargs):
return []
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(get_activity_messages(activity_id="i123"))
assert "No messages found" in result
def test_add_activity_message(monkeypatch):
"""Test add_activity_message posts a message and returns confirmation."""
async def fake_request(*_args, **kwargs):
assert kwargs.get("method") == "POST"
assert kwargs.get("data") == {"content": "Great run!"}
return {"id": 42, "new_chat": None}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(add_activity_message(activity_id="i123", content="Great run!"))
assert "Successfully added message" in result
assert "42" in result
def test_add_activity_message_missing_id(monkeypatch):
"""Test add_activity_message warns when response has no ID."""
async def fake_request(*_args, **_kwargs):
return {"new_chat": None}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(add_activity_message(activity_id="i123", content="Hello"))
assert "appears to have been added" in result
assert "verify manually" in result
def test_add_activity_message_unexpected_response(monkeypatch):
"""Test add_activity_message handles unexpected non-dict response."""
async def fake_request(*_args, **_kwargs):
return None
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(add_activity_message(activity_id="i123", content="Hello"))
assert "Unexpected response" in result
def test_add_activity_message_error(monkeypatch):
"""Test add_activity_message handles API errors."""
async def fake_request(*_args, **_kwargs):
return {"error": True, "message": "Not found"}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
result = asyncio.run(add_activity_message(activity_id="i999", content="Hello"))
assert "Error adding message" in result
def test_get_athlete_power_curves(monkeypatch):
"""
Test get_athlete_power_curves returns formatted power curve data with both seasons.
"""
async def fake_request(*_args, **_kwargs):
return POWER_CURVES_DATA
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
)
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
athlete_id="i1",
)
)
assert "Power Curves (Ride):" in result
assert "This season" in result
assert "Last season" in result
assert "5s:" in result
assert "W/kg" in result
assert "i100" in result
def test_get_athlete_power_curves_custom_durations(monkeypatch):
"""
Test get_athlete_power_curves with custom durations returns only those durations.
"""
async def fake_request(*_args, **_kwargs):
return POWER_CURVES_DATA
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
)
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
durations=[5, 60],
athlete_id="i1",
)
)
assert "5s:" in result
assert "1m:" in result
# Should not contain durations we didn't request
assert "15s:" not in result
assert "10m:" not in result
def test_get_athlete_power_curves_without_normalised(monkeypatch):
"""
Test get_athlete_power_curves without normalised data excludes W/kg values.
"""
async def fake_request(*_args, **_kwargs):
return POWER_CURVES_DATA
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
)
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
include_normalised=False,
athlete_id="i1",
)
)
assert "W/kg" not in result
assert "780W" in result
def test_get_athlete_power_curves_date_validation(monkeypatch):
"""
Test get_athlete_power_curves validates date parameters.
"""
async def fake_request(*_args, **_kwargs):
return POWER_CURVES_DATA
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
)
# Only start_date without end_date should fail
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
start_date="2026-01-01",
athlete_id="i1",
)
)
assert "Error" in result
assert "start_date and end_date must be provided together" in result
def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
"""
Test get_athlete_power_curves returns error when no curves selected.
"""
async def fake_request(*_args, **_kwargs):
return POWER_CURVES_DATA
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
)
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
this_season=False,
last_season=False,
athlete_id="i1",
)
)
assert "Error" in result
assert "At least one curve must be selected" in result
def test_get_custom_items(monkeypatch):
"""
Test get_custom_items returns a formatted string containing custom item details.
"""
custom_items = [
{"id": 1, "name": "HR Zones", "type": "ZONES", "description": "Heart rate zones"},
{"id": 2, "name": "Power Chart", "type": "FITNESS_CHART", "description": None},
]
async def fake_request(*_args, **_kwargs):
return custom_items
# Patch in both api.client and tools modules to ensure it works
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(get_custom_items(athlete_id="1"))
assert "Custom Items:" in result
assert "HR Zones" in result
assert "ZONES" in result
assert "Power Chart" in result
def test_get_custom_item_by_id(monkeypatch):
"""
Test get_custom_item_by_id returns formatted details of a single custom item.
"""
custom_item = {
"id": 1,
"name": "HR Zones",
"type": "ZONES",
"description": "Heart rate zones",
"visibility": "PRIVATE",
"index": 0,
}
async def fake_request(*_args, **_kwargs):
return custom_item
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(get_custom_item_by_id(item_id=1, athlete_id="1"))
assert "Custom Item Details:" in result
assert "HR Zones" in result
assert "ZONES" in result
assert "Heart rate zones" in result
assert "PRIVATE" in result
def test_create_custom_item(monkeypatch):
"""
Test create_custom_item returns a success message with formatted item details.
"""
created_item = {
"id": 10,
"name": "New Chart",
"type": "FITNESS_CHART",
"description": "A new fitness chart",
"visibility": "PRIVATE",
}
async def fake_request(*_args, **_kwargs):
return created_item
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(
create_custom_item(name="New Chart", item_type="FITNESS_CHART", athlete_id="1")
)
assert "Successfully created custom item:" in result
assert "New Chart" in result
assert "FITNESS_CHART" in result
def test_create_custom_item_with_string_content(monkeypatch):
"""
Test create_custom_item correctly parses content when passed as a JSON string.
"""
captured: dict = {}
async def fake_request(*_args, **kwargs):
captured["data"] = kwargs.get("data")
return {
"id": 11,
"name": "Activity Field",
"type": "ACTIVITY_FIELD",
"content": {"expression": "icu_training_load"},
}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(
create_custom_item(
name="Activity Field",
item_type="ACTIVITY_FIELD",
athlete_id="1",
content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
)
)
assert "Successfully created custom item:" in result
# Verify the content was parsed from string to dict before being sent
assert isinstance(captured["data"]["content"], dict)
assert captured["data"]["content"]["expression"] == "icu_training_load"
def test_update_custom_item(monkeypatch):
"""
Test update_custom_item returns a success message with formatted item details.
"""
updated_item = {
"id": 1,
"name": "Updated Chart",
"type": "FITNESS_CHART",
"description": "Updated description",
"visibility": "PUBLIC",
}
async def fake_request(*_args, **_kwargs):
return updated_item
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(
update_custom_item(item_id=1, name="Updated Chart", athlete_id="1")
)
assert "Successfully updated custom item:" in result
assert "Updated Chart" in result
assert "PUBLIC" in result
def test_delete_custom_item(monkeypatch):
"""
Test delete_custom_item returns the API response.
"""
async def fake_request(*_args, **_kwargs):
return {}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(delete_custom_item(item_id=1, athlete_id="1"))
assert "Successfully deleted" in result
def test_create_custom_item_with_invalid_json_content(monkeypatch):
"""
Test create_custom_item returns an error message when content is an invalid JSON string.
"""
async def fake_request(*_args, **_kwargs):
return {}
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
)
result = asyncio.run(
create_custom_item(
name="Bad Item",
item_type="FITNESS_CHART",
athlete_id="1",
content="not valid json", # type: ignore[arg-type]
)
)
assert "Error: content must be valid JSON when passed as a string." in result
# ---------------------------------------------------------------------------
# Gear tools
# ---------------------------------------------------------------------------
def test_get_gear_list(monkeypatch):
"""
Test get_gear_list returns a formatted catalog with id, type, name and stats.
"""
_reset_gear_cache()
sample_gear = [
{
"id": "b1",
"type": "Bike",
"name": "Litening Air",
"default_for_type": "Ride",
"activities": 100,
"distance": 4_155_700,
"retired": False,
},
{
"id": "b2",
"type": "Bike",
"name": "Retired bike",
"activities": 50,
"distance": 2_000_000,
"retired": True,
},
]
async def fake_request(*_args, **_kwargs):
return sample_gear
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
)
result = asyncio.run(get_gear_list(athlete_id="i1"))
assert "Gear catalog for athlete i1:" in result
assert "Litening Air" in result
assert "b1" in result
assert "Retired bike" in result
assert "yes" in result # retired flag rendered
assert "Ride" in result # default_for_type rendered
def test_get_gear_list_empty(monkeypatch):
"""
Test get_gear_list returns an informative message when no gear is configured.
"""
_reset_gear_cache()
async def fake_request(*_args, **_kwargs):
return []
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
)
result = asyncio.run(get_gear_list(athlete_id="i1"))
assert "No gear found" in result
def test_get_gear_list_cache_and_refresh(monkeypatch):
"""
Test that get_gear_list caches the catalog and that refresh=True busts the cache.
"""
_reset_gear_cache()
call_count = {"n": 0}
sample_gear = [
{
"id": "b1",
"type": "Bike",
"name": "Litening Air",
"activities": 100,
"distance": 4_155_700,
}
]
async def fake_request(*_args, **_kwargs):
call_count["n"] += 1
return sample_gear
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
)
# First call: cache cold, one API hit expected.
asyncio.run(get_gear_list(athlete_id="i1"))
assert call_count["n"] == 1
# Second call: cache warm, no additional API hit.
asyncio.run(get_gear_list(athlete_id="i1"))
assert call_count["n"] == 1
# refresh=True busts the cache and triggers a fresh fetch.
asyncio.run(get_gear_list(athlete_id="i1", refresh=True))
assert call_count["n"] == 2
def test_get_activity_details_resolves_gear_name(monkeypatch):
"""
Test get_activity_details injects the resolved gear name into the formatted output
when the activity payload contains a gear_id.
"""
_reset_gear_cache()
activity = {
"name": "Morning Ride",
"id": 123,
"type": "Ride",
"startTime": "2024-01-01T08:00:00Z",
"distance": 1000,
"duration": 3600,
"gear_id": "b1",
}
gear_catalog = [{"id": "b1", "type": "Bike", "name": "Litening Air"}]
async def fake_request(url=None, **_kwargs):
# The activity endpoint and the gear endpoint share the same fake
# request; route by URL pattern.
if url and "/gear" in url:
return gear_catalog
return activity
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
monkeypatch.setattr(
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
)
# get_activity_details does not accept athlete_id; gear resolution falls
# back to the configured ATHLETE_ID, which is unset under CI. Provide one.
monkeypatch.setattr(gear_module.config, "athlete_id", "1")
result = asyncio.run(get_activity_details(123))
assert "Activity: Morning Ride" in result
assert "Gear:" in result
assert "Name: Litening Air" in result
assert "ID: b1" in result
def test_get_activities_resolves_gear_name(monkeypatch):
"""
Test get_activities injects resolved gear names for each activity in the list.
"""
_reset_gear_cache()
activities = [
{
"name": "Ride 1",
"id": 1,
"type": "Ride",
"startTime": "2024-01-01T08:00:00Z",
"distance": 1000,
"duration": 3600,
"gear_id": "b1",
},
{
"name": "Ride 2",
"id": 2,
"type": "Ride",
"startTime": "2024-01-02T08:00:00Z",
"distance": 2000,
"duration": 5400,
"gear_id": "b2",
},
]
gear_catalog = [
{"id": "b1", "type": "Bike", "name": "Litening Air"},
{"id": "b2", "type": "Bike", "name": "S-Works Tarmac SL8"},
]
async def fake_request(url=None, **_kwargs):
if url and "/gear" in url:
return gear_catalog
return activities
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
monkeypatch.setattr(
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
)
monkeypatch.setattr(
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
)
result = asyncio.run(get_activities(athlete_id="1", limit=2, include_unnamed=True))
assert "Ride 1" in result
assert "Ride 2" in result
assert "Name: Litening Air" in result
assert "Name: S-Works Tarmac SL8" in result
+54
View File
@@ -0,0 +1,54 @@
"""
Unit tests for resolve_activity_type in intervals_mcp_server.utils.validation.
"""
from intervals_mcp_server.utils.validation import resolve_activity_type
def test_explicit_activity_type_returned_as_is():
"""Explicit activity_type is returned unchanged."""
assert resolve_activity_type(None, "VirtualRide") == "VirtualRide"
assert resolve_activity_type("morning swim", "Run") == "Run"
def test_keyword_ride():
"""Cycling keywords resolve to Ride."""
for name in ["Morning Ride", "cycling session", "bike workout", "cycle"]:
assert resolve_activity_type(name) == "Ride"
def test_keyword_run():
"""Running keywords resolve to Run."""
for name in ["Easy Run", "jogging", "morning jog", "running"]:
assert resolve_activity_type(name) == "Run"
def test_keyword_swim():
"""Swimming keywords resolve to Swim."""
for name in ["Pool Swim", "swimming drills", "swim"]:
assert resolve_activity_type(name) == "Swim"
def test_keyword_walk():
"""Walking keywords resolve to Walk."""
for name in ["Evening Walk", "hiking trip", "hike", "walking"]:
assert resolve_activity_type(name) == "Walk"
def test_keyword_row():
"""Rowing keywords resolve to Row."""
for name in ["Rowing session", "morning row"]:
assert resolve_activity_type(name) == "Row"
def test_default_ride_when_no_match():
"""Defaults to Ride when no keyword matches."""
assert resolve_activity_type("stretching") == "Ride"
assert resolve_activity_type(None) == "Ride"
assert resolve_activity_type("") == "Ride"
def test_case_insensitive():
"""Keyword matching is case-insensitive."""
assert resolve_activity_type("MORNING RUN") == "Run"
assert resolve_activity_type("SWIM") == "Swim"
+38
View File
@@ -0,0 +1,38 @@
"""
Unit tests for the Value dataclass in intervals_mcp_server.utils.types.
These tests verify that the Value dataclass correctly handles:
- String formatting for percent FTP units
- Ramp intervals (start/end values)
- Deserialisation of pace/swim-pace unit strings returned by the Intervals.icu API
"""
import pytest
from intervals_mcp_server.utils.types import Value, ValueUnits
def test_str_percent_ftp():
"""Test formatting percentage FTP values."""
val = Value(value=95.0, units=ValueUnits.PERCENT_FTP)
assert str(val) == "95% ftp"
def test_str_ramp_percent_ftp():
"""Test formatting ramp intervals with percentage FTP."""
val = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP)
assert str(val) == "65%-85% ftp"
@pytest.mark.parametrize("unit_str,expected_enum", [
("MINS_KM", ValueUnits.MINS_KM),
("MINS_MILE", ValueUnits.MINS_MILE),
("SECS_100M", ValueUnits.SECS_100M),
("SECS_500M", ValueUnits.SECS_500M),
])
def test_pace_units_deserialise_from_api_string(unit_str, expected_enum):
"""Pace/swim-pace unit strings returned by the Intervals.icu API must round-trip
through Value.from_dict without raising ValueError. This test would fail if any
of these unit strings were missing from the ValueUnits enum."""
val = Value.from_dict({"value": 5.0, "units": unit_str})
assert val.units == expected_enum
Generated
+1283
View File
File diff suppressed because it is too large Load Diff