feat: standardize on ISO 8601 for dates, add utility functions, and document design decisions.
This commit is contained in:
@@ -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)
|
- 🤖 **Agent-First Design** — includes specific instructions for LLMs on handling European date formats (DD.MM.YYYY)
|
||||||
- 🌐 **Streamable HTTP transport** for broad client compatibility (SSE)
|
- 🌐 **Streamable HTTP transport** for broad client compatibility (SSE)
|
||||||
- 🔒 **Read-only** — no write operations, safe to use with AI agents
|
- 🔒 **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)
|
- [Connecting with MCP Clients](#connecting-with-mcp-clients)
|
||||||
- [MCP Primitives](#mcp-primitives)
|
- [MCP Primitives](#mcp-primitives)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
|
- [Design Decisions](#design-decisions)
|
||||||
- [CI/CD (Gitea Actions)](#cicd-gitea-actions)
|
- [CI/CD (Gitea Actions)](#cicd-gitea-actions)
|
||||||
- [Known Strava API Limitations](#known-strava-api-limitations)
|
- [Known Strava API Limitations](#known-strava-api-limitations)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
@@ -76,6 +78,17 @@ uv run server
|
|||||||
uv run auth
|
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
|
## 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)
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
Our pipeline (`.gitea/workflows/cicd.yml`) is fully automated:
|
Our pipeline (`.gitea/workflows/cicd.yml`) is fully automated:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -42,7 +42,13 @@ def main() -> None:
|
|||||||
|
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"Strava MCP Server",
|
"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,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
streamable_http_path="/mcp",
|
streamable_http_path="/mcp",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
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.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:
|
def register(mcp: FastMCP, strava: StravaClient) -> None:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -9,16 +10,15 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
before: int | None = None,
|
before: str | None = None,
|
||||||
after: int | None = None,
|
after: str | None = None,
|
||||||
) -> list[TextContent]:
|
) -> list[TextContent]:
|
||||||
"""
|
"""
|
||||||
List recent Strava activities for the authenticated user.
|
List recent Strava activities for the authenticated user.
|
||||||
:param limit: Number of activities to return per page (default 10, max 200).
|
:param limit: Number of activities to return per page (default 10, max 200).
|
||||||
:param page: Page number for pagination (default 1).
|
:param page: Page number for pagination (default 1).
|
||||||
:param before: Unix timestamp — only return activities before this time.
|
:param before: ISO 8601 date string (e.g., '2024-05-01T00:00:00Z') — only return activities before this time.
|
||||||
:param after: Unix timestamp — only return activities after this time.
|
:param after: ISO 8601 date string (e.g., '2024-04-01T00:00:00Z') — only return activities after this time.
|
||||||
Example: 1704067200 = 2024-01-01 00:00:00 UTC.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await ctx.info(f"Fetching activities (Page {page}, Limit {limit})...")
|
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(
|
activities = await strava.list_activities(
|
||||||
limit=min(limit, 200),
|
limit=min(limit, 200),
|
||||||
page=page,
|
page=page,
|
||||||
before=before,
|
before=parse_iso_to_unix(before),
|
||||||
after=after,
|
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 = []
|
essential_data = []
|
||||||
for a in activities:
|
for a in activities:
|
||||||
|
start_date_raw = a.get("start_date")
|
||||||
essential_data.append({
|
essential_data.append({
|
||||||
"id": a["id"],
|
"id": a["id"],
|
||||||
"name": a["name"],
|
"name": a["name"],
|
||||||
"sport_type": a.get("sport_type") or a.get("type"),
|
"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",
|
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
|
||||||
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
|
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
|
||||||
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
|
"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"
|
markdown_summary += "|-------|-------|------|---------|------|------------|------|\n"
|
||||||
for a in essential_data:
|
for a in essential_data:
|
||||||
hr = f"{a['average_heartrate']:.0f} bpm" if a['average_heartrate'] else "-"
|
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 [
|
return [
|
||||||
TextContent(type="text", text=markdown_summary.strip()),
|
|
||||||
TextContent(
|
TextContent(
|
||||||
type="text",
|
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:
|
except Exception as e:
|
||||||
@@ -112,7 +109,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"text": c.get("text"),
|
"text": c.get("text"),
|
||||||
"athlete": f"{c.get('athlete', {}).get('firstname')} {c.get('athlete', {}).get('lastname')}",
|
"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
|
for c in comments
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
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.strava_client import StravaClient
|
||||||
|
from strava_mcp_server.utils import format_date_iso, format_date_human
|
||||||
|
|
||||||
def register(mcp: FastMCP, strava: StravaClient) -> None:
|
def register(mcp: FastMCP, strava: StravaClient) -> None:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -16,17 +17,6 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
|
|
||||||
athlete = await strava.get_athlete()
|
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_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"
|
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"),
|
"measurement_units": athlete.get("measurement_preference"),
|
||||||
"is_premium": athlete.get("premium", False),
|
"is_premium": athlete.get("premium", False),
|
||||||
"profile_medium": athlete.get("profile_medium"),
|
"profile_medium": athlete.get("profile_medium"),
|
||||||
"created_at": athlete.get("created_at"),
|
"created_at": format_date_iso(athlete.get("created_at")),
|
||||||
"updated_at": athlete.get("updated_at"),
|
"updated_at": format_date_iso(athlete.get("updated_at")),
|
||||||
"bio": athlete.get("bio"),
|
"bio": athlete.get("bio"),
|
||||||
"follower_count": athlete.get("follower_count"),
|
"follower_count": athlete.get("follower_count"),
|
||||||
"friend_count": athlete.get("friend_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'}
|
- Measurement Units: {essential_data['measurement_units'] or 'N/A'}
|
||||||
- Strava Summit Member: {'Yes' if essential_data['is_premium'] else 'No'}
|
- Strava Summit Member: {'Yes' if essential_data['is_premium'] else 'No'}
|
||||||
- Profile Image (Medium): {essential_data['profile_medium'] or 'N/A'}
|
- Profile Image (Medium): {essential_data['profile_medium'] or 'N/A'}
|
||||||
- Joined Strava: {format_date(essential_data['created_at'])}
|
- Joined Strava: {format_date_human(essential_data['created_at'])}
|
||||||
- Last Updated: {format_date(essential_data['updated_at'])}
|
- Last Updated: {format_date_human(essential_data['updated_at'])}
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TextContent(type="text", text=markdown_summary),
|
|
||||||
TextContent(
|
TextContent(
|
||||||
type="text",
|
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)]
|
return [TextContent(type="text", text=error_msg)]
|
||||||
|
|
||||||
@mcp.tool()
|
@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.
|
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).
|
Returns zone boundaries for both heart rate and power (if a power meter is configured).
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
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()
|
@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.
|
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.
|
Includes all-time, year-to-date, and recent (4-week) totals for runs, rides, and swims.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
|
await ctx.info("Fetching athlete statistics...")
|
||||||
stats = await strava.get_athlete_stats()
|
stats = await strava.get_athlete_stats()
|
||||||
|
|
||||||
def fmt_sport(s: dict) -> dict:
|
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",
|
"elevation_gain": f"{s.get('elevation_gain', 0):.0f} m",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
# Prepare structured data for Markdown
|
||||||
"all_time": {
|
all_time = {
|
||||||
"runs": fmt_sport(stats.get("all_run_totals", {})),
|
"Laufen": fmt_sport(stats.get("all_run_totals", {})),
|
||||||
"rides": fmt_sport(stats.get("all_ride_totals", {})),
|
"Radfahren": fmt_sport(stats.get("all_ride_totals", {})),
|
||||||
"swims": fmt_sport(stats.get("all_swim_totals", {})),
|
"Schwimmen": 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", {})),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
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:
|
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)]
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
:param weeks: Number of weeks to cover (default 4).
|
:param weeks: Number of weeks to cover (default 4).
|
||||||
Fetches recent activities and athlete stats to produce a summary report.
|
Fetches recent activities and athlete stats to produce a summary report.
|
||||||
"""
|
"""
|
||||||
import time
|
from datetime import datetime, timedelta, timezone
|
||||||
after_ts = int(time.time()) - weeks * 7 * 24 * 3600
|
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 (
|
return (
|
||||||
f"Please summarize my Strava training for the last {weeks} weeks.\n\n"
|
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. "
|
"and a high limit to fetch all recent activities. "
|
||||||
"Also read the get_athlete_stats tool for overall totals.\n\n"
|
"Also read the get_athlete_stats tool for overall totals.\n\n"
|
||||||
"Structure the report as follows:\n"
|
"Structure the report as follows:\n"
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user