refactor: migrate tool outputs to use EmbeddedResource with typed JSON for assistant-facing data
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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"])
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"])
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user