Compare commits

2 Commits

Author SHA1 Message Date
matthias 04663d7ba1 feat: add static strava://activity/last/map resource to show up in MCP Inspector Resources tab
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m27s
2026-05-30 19:00:38 +02:00
matthias 3164e91be9 feat: expose activity map as a native MCP App resource with _meta ui resourceUri
CI/CD Pipeline / Publish to PyPI (push) Successful in 12s
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m28s
2026-05-30 18:48:33 +02:00
4 changed files with 96 additions and 21 deletions
+4 -2
View File
@@ -113,7 +113,8 @@ async def generate():
result = await get_activity_map(ctx, 99999)
html_text = None
for item in result:
content_list = result.content if hasattr(result, "content") else result
for item in content_list:
if isinstance(item, TextContent):
if "<!DOCTYPE html>" in item.text:
html_text = item.text
@@ -191,7 +192,8 @@ async def generate():
# Find the TextContent that holds the HTML
html_text = None
for item in result:
content_list = result.content if hasattr(result, "content") else result
for item in content_list:
if isinstance(item, TextContent):
if "<!DOCTYPE html>" in item.text:
html_text = item.text
+1 -1
View File
@@ -1,3 +1,3 @@
"""Strava MCP Server"""
__version__ = "0.2.4"
__version__ = "0.2.6"
+72 -10
View File
@@ -1,6 +1,12 @@
import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from mcp.types import (
TextContent,
Annotations,
EmbeddedResource,
TextResourceContents,
CallToolResult,
)
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import (
parse_iso_to_unix,
@@ -261,6 +267,53 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
except Exception as e:
return [TextContent(type="text", text=f"Error fetching kudoers: {str(e)}")]
@mcp.resource("strava://activity/{activity_id}/map")
async def get_activity_map_resource(activity_id: int) -> str:
"""HTML map resource for a specific Strava activity."""
class DummyContext:
async def info(self, msg):
pass
async def warning(self, msg):
pass
async def error(self, msg):
pass
ctx = DummyContext()
result = await get_activity_map(ctx, activity_id)
if result and result.content:
return result.content[0].text
return "<html><body><p>Error loading map</p></body></html>"
@mcp.resource("strava://activity/last/map")
async def get_last_activity_map_resource() -> str:
"""HTML map resource for the most recent Strava activity."""
class DummyContext:
async def info(self, msg):
pass
async def warning(self, msg):
pass
async def error(self, msg):
pass
ctx = DummyContext()
try:
activities = await strava.list_activities(limit=1)
if not activities:
return "<html><body><p>Keine Strava-Aktivitäten gefunden.</p></body></html>"
last_id = activities[0]["id"]
result = await get_activity_map(ctx, last_id)
if result and result.content:
return result.content[0].text
except Exception as e:
return f"<html><body><p>Error loading last activity: {str(e)}</p></body></html>"
return "<html><body><p>Error loading map</p></body></html>"
@mcp.tool()
async def get_laps_by_activity_id(activity_id: int):
"""
@@ -396,7 +449,10 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
if not polyline:
warning_msg = f"⚠️ Keine GPS-Routen-Daten für diese Aktivität verfügbar (ID: {activity_id})."
await ctx.warning(warning_msg)
return [TextContent(type="text", text=warning_msg)]
return CallToolResult(
content=[TextContent(type="text", text=warning_msg)],
isError=True,
)
name = activity.get("name", "Aktivität")
sport_type = activity.get("sport_type") or activity.get("type", "Aktivität")
@@ -844,14 +900,20 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
</script>
</body>
</html>"""
return [
TextContent(type="text", text=html_content.strip()),
TextContent(
type="text",
text="[System: Render the above content directly as an HTML block so the UI can iframe it.]",
),
]
resource_uri = f"strava://activity/{activity_id}/map"
return CallToolResult(
content=[
TextContent(type="text", text=html_content.strip()),
TextContent(
type="text",
text="[System: Render the above content directly as an HTML block so the UI can iframe it.]",
),
],
_meta={"ui": {"resourceUri": resource_uri}},
)
except Exception as e:
error_msg = f"Error rendering activity map: {str(e)}"
await ctx.error(error_msg)
return [TextContent(type="text", text=error_msg)]
return CallToolResult(
content=[TextContent(type="text", text=error_msg)], isError=True
)
+19 -8
View File
@@ -15,6 +15,12 @@ class MockMCP:
return decorator
def resource(self, path):
def decorator(func):
return func
return decorator
@pytest.mark.asyncio
async def test_get_activity_map_success():
@@ -46,12 +52,16 @@ async def test_get_activity_map_success():
get_activity_map = mcp.tools["get_activity_map"]
result = await get_activity_map(ctx, 12345)
assert len(result) == 2
assert result[0].type == "text"
html_text = result[0].text
assert result.isError is not True
assert result.meta == {"ui": {"resourceUri": "strava://activity/12345/map"}}
assert result[1].type == "text"
assert "Render the above content" in result[1].text
content = result.content
assert len(content) == 2
assert content[0].type == "text"
html_text = content[0].text
assert content[1].type == "text"
assert "Render the above content" in content[1].text
assert "<!DOCTYPE html>" in html_text
assert "unpkg.com/leaflet" in html_text
@@ -86,6 +96,7 @@ async def test_get_activity_map_no_polyline():
get_activity_map = mcp.tools["get_activity_map"]
result = await get_activity_map(ctx, 67890)
assert len(result) == 1
assert result[0].type == "text"
assert "⚠️ Keine GPS-Routen-Daten" in result[0].text
assert result.isError is True
assert len(result.content) == 1
assert result.content[0].type == "text"
assert "⚠️ Keine GPS-Routen-Daten" in result.content[0].text