From c69e3626358972c2de5812cbcf658288dd047a8a Mon Sep 17 00:00:00 2001 From: Matthias Hinrichs Date: Tue, 12 May 2026 23:23:23 +0200 Subject: [PATCH] refactor: migrate tool outputs to use EmbeddedResource with typed JSON for assistant-facing data --- docs/DESIGN_DECISIONS.md | 10 +++--- src/strava_mcp_server/tools/activities.py | 14 ++++++--- src/strava_mcp_server/tools/athlete.py | 38 +++++++++++++++-------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index fcfbd3d..5b8d3b4 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -21,12 +21,12 @@ This document records key architectural and design decisions made during the dev - 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. +**Decision**: Use dual-content outputs with audience-specific annotations and native MIME typing. +**Context**: To provide a clean user experience while giving the LLM detailed data, we split tool results into two parts and use protocol-level typing. **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. +- **User Facing**: Markdown summaries annotated with `audience=["user"]` using `TextContent`. +- **Assistant Facing**: Raw JSON data provided as an `EmbeddedResource` with `mimeType="application/json"` and annotated with `audience=["assistant"]`. +- This ensures the LLM explicitly knows the data format while allowing clients to optimize the user-facing UI. ## 4. Internationalization **Decision**: Use metric units (meters, km, m/s) internally and provide clear conversion instructions to the LLM. diff --git a/src/strava_mcp_server/tools/activities.py b/src/strava_mcp_server/tools/activities.py index 6d4fa34..e9bb5b7 100644 --- a/src/strava_mcp_server/tools/activities.py +++ b/src/strava_mcp_server/tools/activities.py @@ -1,6 +1,6 @@ import json from mcp.server.fastmcp import FastMCP, Context -from mcp.types import TextContent, Annotations +from mcp.types import ContentBlock, TextContent, Annotations, EmbeddedResource, TextResourceContents from strava_mcp_server.strava_client import StravaClient from strava_mcp_server.utils import parse_iso_to_unix, format_date_iso, format_date_human @@ -12,7 +12,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: page: int = 1, before: str | None = None, after: str | None = None, - ) -> list[TextContent]: + ) -> list[ContentBlock]: """ List recent Strava activities for the authenticated user. :param limit: Number of activities to return per page (default 10, max 200). @@ -62,9 +62,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: text=markdown_summary.strip(), annotations=Annotations(audience=["user"]) ), - TextContent( - type="text", - text=json.dumps(essential_data, indent=2), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="internal://activities/list", + mimeType="application/json", + text=json.dumps(essential_data, indent=2) + ), annotations=Annotations(audience=["assistant"]) ) ] diff --git a/src/strava_mcp_server/tools/athlete.py b/src/strava_mcp_server/tools/athlete.py index b543ac3..0ff765e 100644 --- a/src/strava_mcp_server/tools/athlete.py +++ b/src/strava_mcp_server/tools/athlete.py @@ -1,12 +1,12 @@ import json from mcp.server.fastmcp import FastMCP, Context -from mcp.types import TextContent, Annotations +from mcp.types import ContentBlock, TextContent, Annotations, EmbeddedResource, TextResourceContents 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() - async def get_athlete_profile(ctx: Context) -> list[TextContent]: + async def get_athlete_profile(ctx: Context) -> list[ContentBlock]: """ Get the authenticated Strava athlete's profile. Returns name, city, country, follower count, and other profile details. @@ -56,9 +56,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: text=markdown_summary, annotations=Annotations(audience=["user"]) ), - TextContent( - type="text", - text=json.dumps(essential_data, indent=2), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="internal://athlete/profile", + mimeType="application/json", + text=json.dumps(essential_data, indent=2) + ), annotations=Annotations(audience=["assistant"]) ) ] @@ -69,7 +73,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: return [TextContent(type="text", text=error_msg)] @mcp.tool() - async def get_athlete_zones(ctx: Context) -> list[TextContent]: + async def get_athlete_zones(ctx: Context) -> list[ContentBlock]: """ 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). @@ -108,9 +112,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: text=markdown_summary.strip(), annotations=Annotations(audience=["user"]) ), - TextContent( - type="text", - text=json.dumps(zones, indent=2), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="internal://athlete/zones", + mimeType="application/json", + text=json.dumps(zones, indent=2) + ), annotations=Annotations(audience=["assistant"]) ) ] @@ -120,7 +128,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: return [TextContent(type="text", text=error_msg)] @mcp.tool() - async def get_athlete_stats(ctx: Context) -> list[TextContent]: + async def get_athlete_stats(ctx: Context) -> list[ContentBlock]: """ 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. @@ -175,9 +183,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None: text=markdown_summary.strip(), annotations=Annotations(audience=["user"]) ), - TextContent( - type="text", - text=json.dumps(stats, indent=2), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="internal://athlete/stats", + mimeType="application/json", + text=json.dumps(stats, indent=2) + ), annotations=Annotations(audience=["assistant"]) ) ]