From 3805ca327404f146cd2e61230a88e124b12a9232 Mon Sep 17 00:00:00 2001 From: Matthias Hinrichs Date: Tue, 12 May 2026 23:09:00 +0200 Subject: [PATCH] feat: standardize on ISO 8601 for dates, add utility functions, and document design decisions. --- README.md | 20 +++ docs/DESIGN_DECISIONS.md | 35 ++++++ src/strava_mcp_server/main.py | 8 +- src/strava_mcp_server/tools/activities.py | 43 +++---- src/strava_mcp_server/tools/athlete.py | 145 ++++++++++++++++------ src/strava_mcp_server/tools/prompts.py | 7 +- src/strava_mcp_server/utils.py | 60 +++++++++ 7 files changed, 250 insertions(+), 68 deletions(-) create mode 100644 docs/DESIGN_DECISIONS.md create mode 100644 src/strava_mcp_server/utils.py diff --git a/README.md b/README.md index 4896d60..62861dd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK] - ๐Ÿค– **Agent-First Design** โ€” includes specific instructions for LLMs on handling European date formats (DD.MM.YYYY) - ๐ŸŒ **Streamable HTTP transport** for broad client compatibility (SSE) - ๐Ÿ”’ **Read-only** โ€” no write operations, safe to use with AI agents +- ๐Ÿ“ **Design Decisions** โ€” documented architectural choices in `docs/DESIGN_DECISIONS.md` --- @@ -29,6 +30,7 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK] - [Connecting with MCP Clients](#connecting-with-mcp-clients) - [MCP Primitives](#mcp-primitives) - [Project Structure](#project-structure) +- [Design Decisions](#design-decisions) - [CI/CD (Gitea Actions)](#cicd-gitea-actions) - [Known Strava API Limitations](#known-strava-api-limitations) - [Troubleshooting](#troubleshooting) @@ -76,6 +78,17 @@ uv run server uv run auth ``` +### Run on the fly with `uvx` (No git clone required) + +You can run the server directly from the repository without cloning it manually by using `uvx`: + +```bash +# Set up your .env file in the current directory first! +uvx --from git+https://git.hnrx.net/hnrx/strava-mcp-server.git server +``` + +*(If you are already inside the cloned directory, you can also just run `uvx --from . server`)* + --- ## Strava API Setup @@ -142,6 +155,13 @@ Add to `claude_desktop_config.json`: --- +## Design Decisions + +For a detailed list of architectural choices, unit standardizations, and LLM-specific optimizations, please refer to: +๐Ÿ‘‰ **[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md)** + +--- + ## CI/CD (Gitea Actions) Our pipeline (`.gitea/workflows/cicd.yml`) is fully automated: diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md new file mode 100644 index 0000000..fcfbd3d --- /dev/null +++ b/docs/DESIGN_DECISIONS.md @@ -0,0 +1,35 @@ +# Design Decisions - Strava MCP Server + +This document records key architectural and design decisions made during the development of the Strava MCP Server. It serves as a guide for AI agents and developers to maintain consistency. + +## 1. Date and Time Handling +**Decision**: Standardize on ISO 8601 (UTC) for all internal data exchange, tool inputs, and tool outputs. +**Date**: 2026-05-12 +**Context**: LLMs often struggle with ambiguous date formats (e.g., DD.MM.YYYY vs. MM/DD/YYYY). International users require a unified format. +**Implementation**: +- All tools accept ISO 8601 strings (`YYYY-MM-DDTHH:MM:SSZ`) for date parameters. +- Tool outputs include a `_date` or similar field with the raw ISO 8601 string. +- A shared utility `src/strava_mcp_server/utils.py` handles parsing and formatting. +- **Human Readability**: While raw data is ISO 8601, markdown summaries presented to the user should use `DD.MM.YYYY HH:MM` for comfort, but the raw data for agent analysis must be standardized. + +## 2. Authentication & Token Management +**Decision**: Automate token rotation and prioritize environment-based configuration. +**Context**: Strava tokens expire every 6 hours. Manual refresh is tedious for automated use. +**Implementation**: +- The server checks token expiration before every request. +- Tokens are automatically refreshed and updated in the environment/memory. +- Initial authentication is handled via a separate `auth` script or integrated OAuth flow. + +## 3. Data Representation & MCP Annotations +**Decision**: Use dual-content outputs with audience-specific annotations. +**Context**: To provide a clean user experience while giving the LLM detailed data, we split tool results into two parts. +**Implementation**: +- **User Facing**: Markdown summaries annotated with `audience=["user"]`. +- **Assistant Facing**: Raw JSON data annotated with `audience=["assistant"]`. +- This allows MCP clients to intelligently decide what to display to the user while ensuring the assistant receives full technical context. + +## 4. Internationalization +**Decision**: Use metric units (meters, km, m/s) internally and provide clear conversion instructions to the LLM. +**Implementation**: +- Strava API returns metric units. +- The LLM is instructed in `main.py` to convert these to human-friendly formats (km, km/h) based on user preference. diff --git a/src/strava_mcp_server/main.py b/src/strava_mcp_server/main.py index 085d1ae..bc10379 100644 --- a/src/strava_mcp_server/main.py +++ b/src/strava_mcp_server/main.py @@ -42,7 +42,13 @@ def main() -> None: mcp = FastMCP( "Strava MCP Server", - instructions="Dates returned by this server are generally in ISO-8601 (UTC) or formatted as DD.MM.YYYY HH:MM. Always present dates, times, and durations to the user in a natural, human-readable format appropriate for their language.", + instructions=""" + IMPORTANT ON DATE/TIME: + - Always use ISO 8601 (UTC) for date/time inputs (YYYY-MM-DDTHH:MM:SSZ). + - This server returns dates in ISO 8601 (UTC). + - When presenting to the user, you may format dates naturally in their local language, but use the raw ISO data for all internal logic and tool calls. + - Distance is in meters (convert to km for users), elevation in meters, and speed in m/s (convert to km/h or pace). + """.strip(), host=host, port=port, streamable_http_path="/mcp", diff --git a/src/strava_mcp_server/tools/activities.py b/src/strava_mcp_server/tools/activities.py index 7a739e4..6d4fa34 100644 --- a/src/strava_mcp_server/tools/activities.py +++ b/src/strava_mcp_server/tools/activities.py @@ -1,7 +1,8 @@ import json from mcp.server.fastmcp import FastMCP, Context -from mcp.types import TextContent +from mcp.types import TextContent, Annotations from strava_mcp_server.strava_client import StravaClient +from strava_mcp_server.utils import parse_iso_to_unix, format_date_iso, format_date_human def register(mcp: FastMCP, strava: StravaClient) -> None: @mcp.tool() @@ -9,16 +10,15 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: ctx: Context, limit: int = 10, page: int = 1, - before: int | None = None, - after: int | None = None, + before: str | None = None, + after: str | None = None, ) -> list[TextContent]: """ List recent Strava activities for the authenticated user. :param limit: Number of activities to return per page (default 10, max 200). :param page: Page number for pagination (default 1). - :param before: Unix timestamp โ€” only return activities before this time. - :param after: Unix timestamp โ€” only return activities after this time. - Example: 1704067200 = 2024-01-01 00:00:00 UTC. + :param before: ISO 8601 date string (e.g., '2024-05-01T00:00:00Z') โ€” only return activities before this time. + :param after: ISO 8601 date string (e.g., '2024-04-01T00:00:00Z') โ€” only return activities after this time. """ try: await ctx.info(f"Fetching activities (Page {page}, Limit {limit})...") @@ -26,27 +26,19 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: activities = await strava.list_activities( limit=min(limit, 200), page=page, - before=before, - after=after, + before=parse_iso_to_unix(before), + after=parse_iso_to_unix(after), ) - from datetime import datetime - def format_date(d_str): - if not d_str: - return "N/A" - try: - d = datetime.fromisoformat(d_str.replace('Z', '+00:00')) - return f"{d.day:02d}.{d.month:02d}.{d.year} {d.hour:02d}:{d.minute:02d}" - except Exception: - return d_str - essential_data = [] for a in activities: + start_date_raw = a.get("start_date") essential_data.append({ "id": a["id"], "name": a["name"], "sport_type": a.get("sport_type") or a.get("type"), - "start_date": format_date(a.get("start_date")), + "start_date": format_date_iso(start_date_raw), + "start_date_local": format_date_human(start_date_raw), "distance": f"{a.get('distance', 0) / 1000:.2f} km", "moving_time": f"{a.get('moving_time', 0) / 60:.1f} min", "total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m", @@ -62,13 +54,18 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: markdown_summary += "|-------|-------|------|---------|------|------------|------|\n" for a in essential_data: hr = f"{a['average_heartrate']:.0f} bpm" if a['average_heartrate'] else "-" - markdown_summary += f"| {a['start_date']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n" + markdown_summary += f"| {a['start_date_local']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n" return [ - TextContent(type="text", text=markdown_summary.strip()), TextContent( type="text", - text=f"Raw Data for Analysis: {json.dumps(essential_data, indent=2)}" + text=markdown_summary.strip(), + annotations=Annotations(audience=["user"]) + ), + TextContent( + type="text", + text=json.dumps(essential_data, indent=2), + annotations=Annotations(audience=["assistant"]) ) ] except Exception as e: @@ -112,7 +109,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: "id": c.get("id"), "text": c.get("text"), "athlete": f"{c.get('athlete', {}).get('firstname')} {c.get('athlete', {}).get('lastname')}", - "created_at": c.get("created_at"), + "created_at": format_date_iso(c.get("created_at")), } for c in comments ] diff --git a/src/strava_mcp_server/tools/athlete.py b/src/strava_mcp_server/tools/athlete.py index d98bb96..b543ac3 100644 --- a/src/strava_mcp_server/tools/athlete.py +++ b/src/strava_mcp_server/tools/athlete.py @@ -1,7 +1,8 @@ import json from mcp.server.fastmcp import FastMCP, Context -from mcp.types import TextContent +from mcp.types import TextContent, Annotations from strava_mcp_server.strava_client import StravaClient +from strava_mcp_server.utils import format_date_iso, format_date_human def register(mcp: FastMCP, strava: StravaClient) -> None: @mcp.tool() @@ -16,17 +17,6 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: athlete = await strava.get_athlete() - from datetime import datetime - - def format_date(d_str): - if not d_str: - return "N/A" - try: - d = datetime.fromisoformat(d_str.replace('Z', '+00:00')) - return f"{d.day}.{d.month}.{d.year}" - except Exception: - return d_str - location_parts = [p for p in (athlete.get('city'), athlete.get('state'), athlete.get('country')) if p] location = ", ".join(location_parts) if location_parts else "N/A" @@ -40,8 +30,8 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: "measurement_units": athlete.get("measurement_preference"), "is_premium": athlete.get("premium", False), "profile_medium": athlete.get("profile_medium"), - "created_at": athlete.get("created_at"), - "updated_at": athlete.get("updated_at"), + "created_at": format_date_iso(athlete.get("created_at")), + "updated_at": format_date_iso(athlete.get("updated_at")), "bio": athlete.get("bio"), "follower_count": athlete.get("follower_count"), "friend_count": athlete.get("friend_count"), @@ -56,15 +46,20 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: - Measurement Units: {essential_data['measurement_units'] or 'N/A'} - Strava Summit Member: {'Yes' if essential_data['is_premium'] else 'No'} - Profile Image (Medium): {essential_data['profile_medium'] or 'N/A'} - - Joined Strava: {format_date(essential_data['created_at'])} - - Last Updated: {format_date(essential_data['updated_at'])} + - Joined Strava: {format_date_human(essential_data['created_at'])} + - Last Updated: {format_date_human(essential_data['updated_at'])} """.strip() return [ - TextContent(type="text", text=markdown_summary), TextContent( type="text", - text=f"Raw Data for Analysis: {json.dumps(essential_data, indent=2)}" + text=markdown_summary, + annotations=Annotations(audience=["user"]) + ), + TextContent( + type="text", + text=json.dumps(essential_data, indent=2), + annotations=Annotations(audience=["assistant"]) ) ] @@ -74,24 +69,64 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: return [TextContent(type="text", text=error_msg)] @mcp.tool() - async def get_athlete_zones(): + async def get_athlete_zones(ctx: Context) -> list[TextContent]: """ Get the heart rate and power zones configured for the authenticated athlete. Returns zone boundaries for both heart rate and power (if a power meter is configured). """ try: - return await strava.get_athlete_zones() + await ctx.info("Fetching athlete zones...") + zones = await strava.get_athlete_zones() + + markdown_summary = "### ๐Ÿ’“ Trainingszonen\n\n" + + # Heart Rate Zones + hr_zones = zones.get("heart_rate", {}).get("zones", []) + if hr_zones: + markdown_summary += "#### Herzfrequenz-Zonen\n" + markdown_summary += "| Zone | Bereich (bpm) |\n" + markdown_summary += "|------|---------------|\n" + for i, z in enumerate(hr_zones): + markdown_summary += f"| {i+1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n" + markdown_summary += "\n" + + # Power Zones + power_zones = zones.get("power", {}).get("zones", []) + if power_zones: + markdown_summary += "#### Leistungs-Zonen (Power)\n" + markdown_summary += "| Zone | Bereich (W) |\n" + markdown_summary += "|------|-------------|\n" + for i, z in enumerate(power_zones): + markdown_summary += f"| {i+1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n" + + if not hr_zones and not power_zones: + markdown_summary = "โš ๏ธ Keine Trainingszonen konfiguriert." + + return [ + TextContent( + type="text", + text=markdown_summary.strip(), + annotations=Annotations(audience=["user"]) + ), + TextContent( + type="text", + text=json.dumps(zones, indent=2), + annotations=Annotations(audience=["assistant"]) + ) + ] except Exception as e: - return f"Error fetching athlete zones: {str(e)}" + error_msg = f"Error fetching athlete zones: {str(e)}" + await ctx.error(error_msg) + return [TextContent(type="text", text=error_msg)] @mcp.tool() - async def get_athlete_stats(): + async def get_athlete_stats(ctx: Context) -> list[TextContent]: """ Get cumulative training statistics for the authenticated Strava athlete. Includes all-time, year-to-date, and recent (4-week) totals for runs, rides, and swims. """ - import json try: + await ctx.info("Fetching athlete statistics...") stats = await strava.get_athlete_stats() def fmt_sport(s: dict) -> dict: @@ -102,23 +137,51 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: "elevation_gain": f"{s.get('elevation_gain', 0):.0f} m", } - result = { - "all_time": { - "runs": fmt_sport(stats.get("all_run_totals", {})), - "rides": fmt_sport(stats.get("all_ride_totals", {})), - "swims": fmt_sport(stats.get("all_swim_totals", {})), - }, - "ytd": { - "runs": fmt_sport(stats.get("ytd_run_totals", {})), - "rides": fmt_sport(stats.get("ytd_ride_totals", {})), - "swims": fmt_sport(stats.get("ytd_swim_totals", {})), - }, - "recent_4_weeks": { - "runs": fmt_sport(stats.get("recent_run_totals", {})), - "rides": fmt_sport(stats.get("recent_ride_totals", {})), - "swims": fmt_sport(stats.get("recent_swim_totals", {})), - }, + # Prepare structured data for Markdown + all_time = { + "Laufen": fmt_sport(stats.get("all_run_totals", {})), + "Radfahren": fmt_sport(stats.get("all_ride_totals", {})), + "Schwimmen": fmt_sport(stats.get("all_swim_totals", {})), } - return json.dumps(result, indent=2) + ytd = { + "Laufen": fmt_sport(stats.get("ytd_run_totals", {})), + "Radfahren": fmt_sport(stats.get("ytd_ride_totals", {})), + "Schwimmen": fmt_sport(stats.get("ytd_swim_totals", {})), + } + recent = { + "Laufen": fmt_sport(stats.get("recent_run_totals", {})), + "Radfahren": fmt_sport(stats.get("recent_ride_totals", {})), + "Schwimmen": fmt_sport(stats.get("recent_swim_totals", {})), + } + + markdown_summary = "### ๐Ÿ“ˆ Trainingsstatistiken\n\n" + + def create_table(title: str, data: dict): + tbl = f"#### {title}\n" + tbl += "| Sport | Aktivitรคten | Distanz | Zeit | Hรถhenmeter |\n" + tbl += "|-------|-------------|---------|------|------------|\n" + for sport, s in data.items(): + if s["count"] > 0: + tbl += f"| {sport} | {s['count']} | {s['distance']} | {s['moving_time']} | {s['elevation_gain']} |\n" + return tbl + "\n" + + markdown_summary += create_table("Letzte 4 Wochen", recent) + markdown_summary += create_table("Dieses Jahr (YTD)", ytd) + markdown_summary += create_table("Gesamt", all_time) + + return [ + TextContent( + type="text", + text=markdown_summary.strip(), + annotations=Annotations(audience=["user"]) + ), + TextContent( + type="text", + text=json.dumps(stats, indent=2), + annotations=Annotations(audience=["assistant"]) + ) + ] except Exception as e: - return f"Error fetching athlete stats: {str(e)}" + error_msg = f"Error fetching athlete stats: {str(e)}" + await ctx.error(error_msg) + return [TextContent(type="text", text=error_msg)] diff --git a/src/strava_mcp_server/tools/prompts.py b/src/strava_mcp_server/tools/prompts.py index 32badb0..43d88a3 100644 --- a/src/strava_mcp_server/tools/prompts.py +++ b/src/strava_mcp_server/tools/prompts.py @@ -29,11 +29,12 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: :param weeks: Number of weeks to cover (default 4). Fetches recent activities and athlete stats to produce a summary report. """ - import time - after_ts = int(time.time()) - weeks * 7 * 24 * 3600 + from datetime import datetime, timedelta, timezone + after_dt = (datetime.now(timezone.utc) - timedelta(weeks=weeks)).replace(hour=0, minute=0, second=0, microsecond=0) + after_iso = after_dt.isoformat().replace('+00:00', 'Z') return ( f"Please summarize my Strava training for the last {weeks} weeks.\n\n" - f"Use list_activities with after={after_ts} (Unix timestamp = {weeks} weeks ago) " + f"Use list_activities with after='{after_iso}' (ISO 8601) " "and a high limit to fetch all recent activities. " "Also read the get_athlete_stats tool for overall totals.\n\n" "Structure the report as follows:\n" diff --git a/src/strava_mcp_server/utils.py b/src/strava_mcp_server/utils.py new file mode 100644 index 0000000..2327c16 --- /dev/null +++ b/src/strava_mcp_server/utils.py @@ -0,0 +1,60 @@ +from datetime import datetime, timezone +from typing import Optional + +def parse_iso_to_unix(iso_str: Optional[str]) -> Optional[int]: + """ + Parses an ISO 8601 string into a Unix timestamp. + Accepts formats like '2024-01-01', '2024-01-01T12:00:00Z', etc. + """ + if not iso_str: + return None + try: + # Remove 'Z' and replace with +00:00 for fromisoformat compatibility if needed + # but fromisoformat in Python 3.11+ handles Z correctly. + # For older versions or varied formats, we use a slightly more robust approach. + clean_iso = iso_str.replace('Z', '+00:00') + dt = datetime.fromisoformat(clean_iso) + # Ensure it has a timezone; default to UTC if missing + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + except Exception: + return None + +def format_date_iso(date_input: Optional[str | datetime]) -> str: + """ + Standardizes a date string or datetime object to ISO 8601 (UTC). + """ + if not date_input: + return "N/A" + + try: + if isinstance(date_input, str): + # Strava dates are often '2024-01-01T12:00:00Z' + dt = datetime.fromisoformat(date_input.replace('Z', '+00:00')) + else: + dt = date_input + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + return dt.isoformat().replace('+00:00', 'Z') + except Exception: + return str(date_input) + +def format_date_human(date_input: Optional[str | datetime]) -> str: + """ + Returns a human-readable date/time string (DD.MM.YYYY HH:MM). + """ + if not date_input: + return "N/A" + + try: + if isinstance(date_input, str): + dt = datetime.fromisoformat(date_input.replace('Z', '+00:00')) + else: + dt = date_input + + return dt.strftime("%d.%m.%Y %H:%M") + except Exception: + return str(date_input)