Compare commits

3 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
matthias ef3b8305ab feat: output raw HTML in get_activity_map for native markdown block rendering
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 1m28s
2026-05-30 18:34:42 +02:00
4 changed files with 105 additions and 35 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.3"
__version__ = "0.2.6"
+72 -12
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,16 +900,20 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
</script>
</body>
</html>"""
escaped_html = html_content.strip().replace('"', "&quot;")
iframe_content = f'<iframe srcdoc="{escaped_html}" style="width: 100%; height: 600px; border: none; border-radius: 12px;"></iframe>'
return [
TextContent(type="text", text=iframe_content),
TextContent(
type="text",
text="[System Context: The interactive map has been generated successfully. You must output the exact <iframe> HTML block from the tool result verbatim in your chat response so that Open WebUI can render it inline. Do not modify the iframe, and do not explain that you cannot render it; just output the tag verbatim.]",
),
]
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
)
+28 -20
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,25 +52,26 @@ 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"
iframe_text = result[0].text
assert iframe_text.startswith('<iframe srcdoc="')
assert iframe_text.endswith('"></iframe>')
assert result.isError is not True
assert result.meta == {"ui": {"resourceUri": "strava://activity/12345/map"}}
assert result[1].type == "text"
assert "System Context" in result[1].text
content = result.content
assert len(content) == 2
assert content[0].type == "text"
html_text = content[0].text
decoded_html = iframe_text.replace("&quot;", '"')
assert "<!DOCTYPE html>" in decoded_html
assert "unpkg.com/leaflet" in decoded_html
assert "latlngData" in decoded_html
assert "[47.1, 9.1]" in decoded_html
assert 'sportEmoji = "🚴"' in decoded_html
assert "L.control.layers" in decoded_html
assert "satellite" in decoded_html
assert "🟢" in decoded_html
assert "🏁" in decoded_html
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
assert "latlngData" in html_text
assert "[47.1, 9.1]" in html_text
assert 'sportEmoji = "🚴"' in html_text
assert "L.control.layers" in html_text
assert "satellite" in html_text
assert "🟢" in html_text
assert "🏁" in html_text
@pytest.mark.asyncio
@@ -89,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