refactor: migrate tool outputs to use EmbeddedResource with typed JSON for assistant-facing data
CI/CD Pipeline / Lint & Check (push) Successful in 9s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m20s

This commit is contained in:
2026-05-12 23:23:23 +02:00
parent 3805ca3274
commit c69e362635
3 changed files with 39 additions and 23 deletions
+5 -5
View File
@@ -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. - Initial authentication is handled via a separate `auth` script or integrated OAuth flow.
## 3. Data Representation & MCP Annotations ## 3. Data Representation & MCP Annotations
**Decision**: Use dual-content outputs with audience-specific annotations. **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. **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**: **Implementation**:
- **User Facing**: Markdown summaries annotated with `audience=["user"]`. - **User Facing**: Markdown summaries annotated with `audience=["user"]` using `TextContent`.
- **Assistant Facing**: Raw JSON data annotated with `audience=["assistant"]`. - **Assistant Facing**: Raw JSON data provided as an `EmbeddedResource` with `mimeType="application/json"` and 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. - This ensures the LLM explicitly knows the data format while allowing clients to optimize the user-facing UI.
## 4. Internationalization ## 4. Internationalization
**Decision**: Use metric units (meters, km, m/s) internally and provide clear conversion instructions to the LLM. **Decision**: Use metric units (meters, km, m/s) internally and provide clear conversion instructions to the LLM.
+9 -5
View File
@@ -1,6 +1,6 @@
import json import json
from mcp.server.fastmcp import FastMCP, Context 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.strava_client import StravaClient
from strava_mcp_server.utils import parse_iso_to_unix, format_date_iso, format_date_human 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, page: int = 1,
before: str | None = None, before: str | None = None,
after: str | None = None, after: str | None = None,
) -> list[TextContent]: ) -> list[ContentBlock]:
""" """
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).
@@ -62,9 +62,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
text=markdown_summary.strip(), text=markdown_summary.strip(),
annotations=Annotations(audience=["user"]) annotations=Annotations(audience=["user"])
), ),
TextContent( EmbeddedResource(
type="text", type="resource",
text=json.dumps(essential_data, indent=2), resource=TextResourceContents(
uri="internal://activities/list",
mimeType="application/json",
text=json.dumps(essential_data, indent=2)
),
annotations=Annotations(audience=["assistant"]) annotations=Annotations(audience=["assistant"])
) )
] ]
+25 -13
View File
@@ -1,12 +1,12 @@
import json import json
from mcp.server.fastmcp import FastMCP, Context 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.strava_client import StravaClient
from strava_mcp_server.utils import format_date_iso, format_date_human 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()
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. Get the authenticated Strava athlete's profile.
Returns name, city, country, follower count, and other profile details. Returns name, city, country, follower count, and other profile details.
@@ -56,9 +56,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
text=markdown_summary, text=markdown_summary,
annotations=Annotations(audience=["user"]) annotations=Annotations(audience=["user"])
), ),
TextContent( EmbeddedResource(
type="text", type="resource",
text=json.dumps(essential_data, indent=2), resource=TextResourceContents(
uri="internal://athlete/profile",
mimeType="application/json",
text=json.dumps(essential_data, indent=2)
),
annotations=Annotations(audience=["assistant"]) annotations=Annotations(audience=["assistant"])
) )
] ]
@@ -69,7 +73,7 @@ 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(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. 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).
@@ -108,9 +112,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
text=markdown_summary.strip(), text=markdown_summary.strip(),
annotations=Annotations(audience=["user"]) annotations=Annotations(audience=["user"])
), ),
TextContent( EmbeddedResource(
type="text", type="resource",
text=json.dumps(zones, indent=2), resource=TextResourceContents(
uri="internal://athlete/zones",
mimeType="application/json",
text=json.dumps(zones, indent=2)
),
annotations=Annotations(audience=["assistant"]) annotations=Annotations(audience=["assistant"])
) )
] ]
@@ -120,7 +128,7 @@ 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_stats(ctx: Context) -> list[TextContent]: async def get_athlete_stats(ctx: Context) -> list[ContentBlock]:
""" """
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.
@@ -175,9 +183,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
text=markdown_summary.strip(), text=markdown_summary.strip(),
annotations=Annotations(audience=["user"]) annotations=Annotations(audience=["user"])
), ),
TextContent( EmbeddedResource(
type="text", type="resource",
text=json.dumps(stats, indent=2), resource=TextResourceContents(
uri="internal://athlete/stats",
mimeType="application/json",
text=json.dumps(stats, indent=2)
),
annotations=Annotations(audience=["assistant"]) annotations=Annotations(audience=["assistant"])
) )
] ]