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
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