From 65585c53b54961dfd809b060a13d9ce43d59f2db Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 20 Jul 2026 15:46:35 -0400 Subject: [PATCH] feat(activities): add search + best-efforts + interval-stats tools search_activities queries by name/keyword; get_activity_best_efforts returns peak values over windows for a stream; get_activity_interval_stats computes aggregate metrics for an arbitrary stream index range (distinct from the per-interval get_activity_intervals). Spec-required params enforced (q, stream, start/end index). Formatters added; tools registered in server.py and __init__. Implements #3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN --- src/intervals_mcp_server/server.py | 6 + src/intervals_mcp_server/tools/__init__.py | 6 + src/intervals_mcp_server/tools/activities.py | 116 +++++++++++++- src/intervals_mcp_server/utils/formatting.py | 62 ++++++++ tests/test_activity_analytics.py | 154 +++++++++++++++++++ 5 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 tests/test_activity_analytics.py diff --git a/src/intervals_mcp_server/server.py b/src/intervals_mcp_server/server.py index 320b701..327f7c6 100644 --- a/src/intervals_mcp_server/server.py +++ b/src/intervals_mcp_server/server.py @@ -71,6 +71,9 @@ 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 + get_activity_best_efforts, + get_activity_interval_stats, + search_activities, add_activity_message, get_activities, get_activity_details, @@ -116,6 +119,9 @@ __all__ = [ "get_activity_intervals", "get_activity_messages", "get_activity_streams", + "search_activities", + "get_activity_best_efforts", + "get_activity_interval_stats", "get_events", "get_event_by_id", "delete_event", diff --git a/src/intervals_mcp_server/tools/__init__.py b/src/intervals_mcp_server/tools/__init__.py index d713d49..f897524 100644 --- a/src/intervals_mcp_server/tools/__init__.py +++ b/src/intervals_mcp_server/tools/__init__.py @@ -10,9 +10,12 @@ from mcp.server.fastmcp import FastMCP # pylint: disable=import-error # Note: Tools register themselves via @mcp.tool() decorators when imported from intervals_mcp_server.tools.activities import ( # noqa: F401 get_activities, + get_activity_best_efforts, get_activity_details, + get_activity_interval_stats, get_activity_intervals, get_activity_streams, + search_activities, ) from intervals_mcp_server.tools.events import ( # noqa: F401 add_or_update_event, @@ -65,6 +68,9 @@ __all__ = [ "get_activity_details", "get_activity_intervals", "get_activity_streams", + "search_activities", + "get_activity_best_efforts", + "get_activity_interval_stats", "get_events", "get_event_by_id", "delete_event", diff --git a/src/intervals_mcp_server/tools/activities.py b/src/intervals_mcp_server/tools/activities.py index 5a54af6..52cb531 100644 --- a/src/intervals_mcp_server/tools/activities.py +++ b/src/intervals_mcp_server/tools/activities.py @@ -14,7 +14,14 @@ 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.formatting import ( + format_activity_message, + format_activity_search_results, + format_activity_summary, + format_best_efforts, + format_interval_stats, + format_intervals, +) from intervals_mcp_server.utils.validation import resolve_date_params # Import mcp instance from shared module for tool registration @@ -405,3 +412,110 @@ async def add_activity_message( 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." + + +@mcp.tool() +async def search_activities(query: str, limit: int = 20) -> str: + """Search the athlete's activities by name/keyword. + + Args: + query: Search text matched against activity name/description (required). + limit: Maximum number of results to return (default 20). + """ + try: + athlete_id_to_use, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) + + if not query or not query.strip(): + return "Error: a non-empty search query is required." + + params: dict[str, Any] = {"q": query.strip(), "limit": limit} + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/activities/search", api_key=api_key, params=params + ) + + if isinstance(result, dict) and "error" in result: + return f"Error searching activities: {result.get('message')}" + + results = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else [] + if not results: + return f"No activities found matching '{query}'." + return format_activity_search_results(results) + + +@mcp.tool() +async def get_activity_best_efforts( + activity_id: str, + stream: str = "watts", + duration: int | None = None, + distance: float | None = None, + count: int | None = None, +) -> str: + """Get the best efforts (peak values over windows) for an activity. + + Args: + activity_id: The Intervals.icu activity ID. + stream: Data stream to analyze — e.g. "watts", "heartrate", "pace" (default "watts"). + duration: Optional window duration in seconds to target. + distance: Optional window distance in meters to target. + count: Optional maximum number of efforts to return. + """ + try: + _athlete_id, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) + + params: dict[str, Any] = {"stream": stream} + if duration is not None: + params["duration"] = duration + if distance is not None: + params["distance"] = distance + if count is not None: + params["count"] = count + + result = await make_intervals_request( + url=f"/activity/{activity_id}/best-efforts", api_key=api_key, params=params + ) + + if isinstance(result, dict) and "error" in result: + return f"Error fetching best efforts: {result.get('message')}" + + efforts = result.get("efforts") if isinstance(result, dict) else None + if not efforts: + return f"No best-effort data found for activity {activity_id} (stream: {stream})." + return format_best_efforts([e for e in efforts if isinstance(e, dict)], stream) + + +@mcp.tool() +async def get_activity_interval_stats(activity_id: str, start_index: int, end_index: int) -> str: + """Compute aggregate stats for an index range of an activity's data streams. + + start_index/end_index are positions in the activity's streams (as seen in the + streams or interval output). This computes metrics for that slice — it does NOT + list the activity's own intervals (use get_activity_intervals for that). + + Args: + activity_id: The Intervals.icu activity ID. + start_index: Start position in the activity streams (required). + end_index: End position in the activity streams (required, > start_index). + """ + try: + _athlete_id, api_key = await credentials.resolve_caller_credentials() + except CredentialError as exc: + return str(exc) + + if start_index < 0 or end_index <= start_index: + return "Error: end_index must be greater than start_index and both non-negative." + + params: dict[str, Any] = {"start_index": start_index, "end_index": end_index} + result = await make_intervals_request( + url=f"/activity/{activity_id}/interval-stats", api_key=api_key, params=params + ) + + if isinstance(result, dict) and "error" in result: + return f"Error fetching interval stats: {result.get('message')}" + + if not isinstance(result, dict) or not result: + return f"No interval stats found for activity {activity_id} ({start_index}-{end_index})." + return format_interval_stats(result) diff --git a/src/intervals_mcp_server/utils/formatting.py b/src/intervals_mcp_server/utils/formatting.py index b2e04a2..089afc8 100644 --- a/src/intervals_mcp_server/utils/formatting.py +++ b/src/intervals_mcp_server/utils/formatting.py @@ -816,3 +816,65 @@ def format_power_curves( lines.append("") return "\n".join(lines) + + +def format_activity_search_results(results: list[dict[str, Any]]) -> str: + """Format activity search hits into a compact one-line-per-result list.""" + lines = [f"Found {len(results)} activit{'y' if len(results) == 1 else 'ies'}:", ""] + for r in results: + date = r.get("start_date_local", "") + if isinstance(date, str) and len(date) > 10: + date = date[:10] + extra = [] + if r.get("distance") is not None: + extra.append(f"{r['distance']}m") + if r.get("moving_time") is not None: + extra.append(f"{r['moving_time']}s") + if r.get("race"): + extra.append("RACE") + line = " | ".join([date or "?", str(r.get("type", "?")), str(r.get("name", "Unnamed"))]) + if extra: + line += " (" + ", ".join(extra) + ")" + line += f" [id: {r.get('id', 'N/A')}]" + lines.append(line) + return "\n".join(lines) + + +def format_best_efforts(efforts: list[dict[str, Any]], stream: str) -> str: + """Format best-effort windows for a stream (power/hr/pace) into text.""" + lines = [f"Best Efforts ({stream}):", ""] + for e in efforts: + parts = [] + if e.get("duration") is not None: + parts.append(_format_duration_label(int(e["duration"]))) + if e.get("distance") is not None: + parts.append(f"{e['distance']}m") + label = " / ".join(parts) if parts else "effort" + idx = f"[idx {e.get('start_index', '?')}-{e.get('end_index', '?')}]" + lines.append(f"- {label}: avg {e.get('average', 'N/A')} {idx}") + return "\n".join(lines) + + +def format_interval_stats(interval: dict[str, Any]) -> str: + """Format a computed interval-stats block (a single Interval) into text.""" + lines = ["Interval Stats:", ""] + for key, label, unit in [ + ("moving_time", "Moving Time", "s"), + ("distance", "Distance", "m"), + ("average_watts", "Avg Power", "W"), + ("weighted_average_watts", "Weighted Avg Power", "W"), + ("max_watts", "Max Power", "W"), + ("average_watts_kg", "Avg Power", "W/kg"), + ("intensity", "Intensity", ""), + ("training_load", "Training Load", ""), + ("joules", "Work", "J"), + ("decoupling", "Decoupling", "%"), + ("average_heartrate", "Avg HR", "bpm"), + ("max_heartrate", "Max HR", "bpm"), + ("average_cadence", "Avg Cadence", "rpm"), + ("average_speed", "Avg Speed", "m/s"), + ("gap", "GAP", "m/s"), + ]: + if interval.get(key) is not None: + lines.append(f"- {label}: {interval[key]}{(' ' + unit) if unit else ''}") + return "\n".join(lines) diff --git a/tests/test_activity_analytics.py b/tests/test_activity_analytics.py new file mode 100644 index 0000000..ef79866 --- /dev/null +++ b/tests/test_activity_analytics.py @@ -0,0 +1,154 @@ +""" +Tests for the 0.3.0 activity search + analytics tools in +intervals_mcp_server.tools.activities: search_activities, +get_activity_best_efforts, get_activity_interval_stats. + +HTTP is stubbed at the module level; the autouse conftest fixture supplies the +caller credentials (athlete ``i1``). +""" + +import asyncio + +from intervals_mcp_server.tools import activities + + +def _patch_request(monkeypatch, result): + calls: list[dict] = [] + + async def fake(**kwargs): + calls.append(kwargs) + return result + + monkeypatch.setattr(activities, "make_intervals_request", fake) + return calls + + +# --------------------------------------------------------------------------- # +# search_activities +# --------------------------------------------------------------------------- # +SEARCH_HITS = [ + { + "id": "a1", + "name": "Threshold intervals", + "start_date_local": "2026-07-18T07:00:00", + "type": "Ride", + "distance": 42000, + "moving_time": 5400, + "race": False, + }, + {"id": "a2", "name": "Local crit", "start_date_local": "2026-07-15", "type": "Ride", "race": True}, +] + + +def test_search_activities_success(monkeypatch): + calls = _patch_request(monkeypatch, SEARCH_HITS) + out = asyncio.run(activities.search_activities("threshold", limit=5)) + assert calls[0]["url"] == "/athlete/i1/activities/search" + assert calls[0]["params"] == {"q": "threshold", "limit": 5} + assert "Found 2 activities" in out + assert "2026-07-18 | Ride | Threshold intervals" in out + assert "[id: a1]" in out + assert "RACE" in out # the crit + + +def test_search_activities_empty_query(monkeypatch): + calls = _patch_request(monkeypatch, SEARCH_HITS) + out = asyncio.run(activities.search_activities(" ")) + assert "non-empty search query is required" in out + assert calls == [] # no request made + + +def test_search_activities_no_results(monkeypatch): + _patch_request(monkeypatch, []) + assert "No activities found matching 'zzz'" in asyncio.run(activities.search_activities("zzz")) + + +def test_search_activities_error(monkeypatch): + _patch_request(monkeypatch, {"error": True, "message": "boom"}) + assert "Error searching activities: boom" in asyncio.run(activities.search_activities("x")) + + +# --------------------------------------------------------------------------- # +# get_activity_best_efforts +# --------------------------------------------------------------------------- # +BEST_EFFORTS = { + "efforts": [ + {"duration": 300, "average": 320, "start_index": 100, "end_index": 400}, + {"distance": 1000, "average": 305, "start_index": 500, "end_index": 700}, + ] +} + + +def test_best_efforts_success_and_param_passthrough(monkeypatch): + calls = _patch_request(monkeypatch, BEST_EFFORTS) + out = asyncio.run( + activities.get_activity_best_efforts("a1", stream="watts", duration=300, count=5) + ) + params = calls[0]["params"] + assert calls[0]["url"] == "/activity/a1/best-efforts" + assert params["stream"] == "watts" + assert params["duration"] == 300 + assert params["count"] == 5 + assert "distance" not in params # None omitted + assert "Best Efforts (watts)" in out + assert "5m: avg 320" in out + assert "1000m: avg 305" in out + + +def test_best_efforts_empty(monkeypatch): + _patch_request(monkeypatch, {"efforts": []}) + out = asyncio.run(activities.get_activity_best_efforts("a1")) + assert "No best-effort data found" in out + + +def test_best_efforts_error(monkeypatch): + _patch_request(monkeypatch, {"error": True, "message": "nope"}) + assert "Error fetching best efforts: nope" in asyncio.run( + activities.get_activity_best_efforts("a1") + ) + + +# --------------------------------------------------------------------------- # +# get_activity_interval_stats +# --------------------------------------------------------------------------- # +INTERVAL_STATS = { + "moving_time": 1200, + "average_watts": 265, + "weighted_average_watts": 272, + "max_watts": 410, + "intensity": 0.88, + "training_load": 45, + "average_heartrate": 158, + "decoupling": 3.2, +} + + +def test_interval_stats_success(monkeypatch): + calls = _patch_request(monkeypatch, INTERVAL_STATS) + out = asyncio.run(activities.get_activity_interval_stats("a1", 100, 500)) + assert calls[0]["url"] == "/activity/a1/interval-stats" + assert calls[0]["params"] == {"start_index": 100, "end_index": 500} + assert "Interval Stats:" in out + assert "Avg Power: 265 W" in out + assert "Weighted Avg Power: 272 W" in out + assert "Decoupling: 3.2 %" in out + + +def test_interval_stats_bad_indices(monkeypatch): + calls = _patch_request(monkeypatch, INTERVAL_STATS) + out = asyncio.run(activities.get_activity_interval_stats("a1", 500, 100)) + assert "end_index must be greater than start_index" in out + assert calls == [] # no request + + +def test_interval_stats_empty(monkeypatch): + _patch_request(monkeypatch, {}) + out = asyncio.run(activities.get_activity_interval_stats("a1", 0, 100)) + assert "No interval stats found" in out + + +def test_interval_stats_error(monkeypatch): + _patch_request(monkeypatch, {"error": True, "message": "bad range"}) + assert "Error fetching interval stats: bad range" in asyncio.run( + activities.get_activity_interval_stats("a1", 0, 100) + )