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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user