Compare commits

10 Commits

Author SHA1 Message Date
matthias f0b806a627 refactor: return str directly from resources to comply with FastMCP design and fix mimeType mismatch
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m33s
2026-05-30 19:38:22 +02:00
matthias 99ee6b7076 fix: enforce mime_type='text/html' inside FastMCP resource decorators and release 0.2.8 2026-05-30 19:34:45 +02:00
matthias 4d127541c5 refactor: remove old duplicate map resource declarations, clean generate_test_map and release version 0.2.7
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 1m27s
2026-05-30 19:31:16 +02:00
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
matthias ab971b8307 feat: add LLM system context to get_activity_map to force verbatim iframe rendering
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m28s
2026-05-30 18:01:02 +02:00
matthias 62ab00bb23 feat: render activity map directly inline using iframe srcdoc
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m39s
2026-05-30 17:36:30 +02:00
matthias f231535f8e chore: ignore test.html in version control
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Publish to PyPI (push) Has been skipped
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m32s
2026-05-30 13:21:39 +02:00
matthias badc2f870e feat(map): add start (🟢) and finish (🏁) route markers
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m32s
2026-05-30 13:20:17 +02:00
5 changed files with 184 additions and 106 deletions
+1
View File
@@ -28,3 +28,4 @@ build/
.Trashes
ehthumbs.db
Thumbs.db
test.html
+22 -29
View File
@@ -8,7 +8,6 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
from strava_mcp_server.strava_client import StravaClient
from mcp.server.fastmcp import Context
from mcp.types import TextContent
class MockContext(Context):
@@ -60,6 +59,7 @@ async def generate():
class MockMCP:
def __init__(self):
self.tools = {}
self.resources = {}
def tool(self):
def decorator(func):
@@ -68,6 +68,13 @@ async def generate():
return decorator
def resource(self, path, **kwargs):
def decorator(func):
self.resources[path] = func
return func
return decorator
mcp = MockMCP()
# Mock StravaClient that returns our mock activity and streams
@@ -106,17 +113,9 @@ async def generate():
client = MockClient()
register(mcp, client)
get_activity_map = mcp.tools["get_activity_map"]
ctx = MockContext()
print("Generating mock map HTML...")
result = await get_activity_map(ctx, 99999)
html_text = None
for item in result:
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
html_text = item.text
break
print("Generating mock map HTML from resource...")
resource_handler = mcp.resources["strava://activity/{activity_id}/map"]
html_text = await resource_handler(99999)
if html_text:
output_path = os.path.join(os.path.dirname(__file__), "..", "test.html")
@@ -155,6 +154,7 @@ async def generate():
class MockMCP:
def __init__(self):
self.tools = {}
self.resources = {}
def tool(self):
def decorator(func):
@@ -163,21 +163,19 @@ async def generate():
return decorator
def resource(self, path, **kwargs):
def decorator(func):
self.resources[path] = func
return func
return decorator
mcp = MockMCP()
register(mcp, client)
get_activity_map = mcp.tools["get_activity_map"]
ctx = MockContext()
print("Generating map HTML...")
result = await get_activity_map(ctx, activity_id)
# Find the TextContent that holds the HTML
html_text = None
for item in result:
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
html_text = item.text
break
print("Generating map HTML from resource...")
resource_handler = mcp.resources["strava://activity/{activity_id}/map"]
html_text = await resource_handler(activity_id)
if html_text:
output_path = os.path.join(os.path.dirname(__file__), "..", "test.html")
@@ -187,11 +185,6 @@ async def generate():
print(
"👉 Double-click 'test.html' or open it in your browser to view the interactive map!"
)
else:
# Maybe it returned a warning message instead of HTML
for item in result:
if isinstance(item, TextContent):
print(f"Returned message: {item.text}")
except Exception as e:
print(f"❌ Failed: {str(e)}")
+1 -1
View File
@@ -1,3 +1,3 @@
"""Strava MCP Server"""
__version__ = "0.1.0"
__version__ = "0.2.8"
+124 -59
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,
@@ -32,6 +38,19 @@ def _user_text(text: str) -> TextContent:
)
def _user_html_resource(uri: str, html: str) -> EmbeddedResource:
"""Helper: return a user-facing EmbeddedResource with text/html."""
return EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri=uri,
mimeType="text/html",
text=html,
),
annotations=Annotations(audience=["user"]),
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
async def list_activities(
@@ -364,15 +383,15 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
except Exception as e:
return f"Error fetching activity streams: {str(e)}"
@mcp.tool()
async def get_activity_map(ctx: Context, activity_id: int):
"""
Get an interactive map visualization for a specific Strava activity by its ID.
:param activity_id: The numeric ID of the Strava activity.
Returns a self-contained HTML page loading Leaflet.js and OpenStreetMap to render the route inside Open WebUI.
"""
async def _render_activity_map_html(
activity_id: int, ctx: Context | None = None
) -> str:
"""Helper: Fetches Strava GPS streams and builds the interactive Leaflet HTML template."""
try:
await ctx.info(f"Fetching activity details for map (ID: {activity_id})...")
if ctx:
await ctx.info(
f"Fetching activity details for map (ID: {activity_id})..."
)
activity = await strava.get_activity(activity_id)
activity_map = activity.get("map", {})
@@ -382,8 +401,9 @@ 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)]
if ctx:
await ctx.warning(warning_msg)
raise ValueError(warning_msg)
name = activity.get("name", "Aktivität")
sport_type = activity.get("sport_type") or activity.get("type", "Aktivität")
@@ -432,9 +452,10 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
elif s.get("type") == "latlng":
latlng_data = s.get("data", [])
except Exception as e:
await ctx.warning(
f"Could not fetch streams for elevation profile: {str(e)}"
)
if ctx:
await ctx.warning(
f"Could not fetch streams for elevation profile: {str(e)}"
)
# Downsample to max 150 points for smooth performance
max_points = 150
@@ -458,14 +479,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {{
display: flex;
flex-direction: column;
height: 100vh;
html, body {{
margin: 0;
padding: 0;
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f9fa;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.map-container {{
position: relative;
@@ -606,39 +629,31 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map);
function decodePolyline(encoded) {{
var points = [];
var index = 0, len = encoded.length;
var lat = 0, lng = 0;
while (index < len) {{
var b, shift = 0, result = 0;
do {{
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
}} while (b >= 0x20);
var dlat = ((result & 1) ? ~(result >> 1) : (result >> 1));
lat += dlat;
shift = 0;
result = 0;
do {{
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
}} while (b >= 0x20);
var dlng = ((result & 1) ? ~(result >> 1) : (result >> 1));
lng += dlng;
points.push([lat / 1e5, lng / 1e5]);
}}
return points;
}}
var distanceData = {json.dumps(distance_data)};
var altitudeData = {json.dumps(altitude_data)};
var latlngData = {json.dumps(latlng_data)};
var encodedPolyline = {json.dumps(polyline)};
var coordinates = decodePolyline(encodedPolyline);
var track;
if (coordinates.length > 0) {{
track = L.polyline(coordinates, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
if (latlngData.length > 0) {{
track = L.polyline(latlngData, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
map.fitBounds(track.getBounds());
// Start- und End-Marker zeichnen
var startIcon = L.divIcon({{
html: '<span style="font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); line-height: 18px; display: block; text-align: center;">🟢</span>',
iconSize: [18, 18],
iconAnchor: [9, 9],
className: 'start-marker-emoji'
}});
L.marker(latlngData[0], {{ icon: startIcon, zIndexOffset: 500 }}).addTo(map);
var endIcon = L.divIcon({{
html: '<span style="font-size: 22px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); line-height: 22px; display: block; text-align: center;">🏁</span>',
iconSize: [22, 22],
iconAnchor: [11, 20],
className: 'end-marker-emoji'
}});
L.marker(latlngData[latlngData.length - 1], {{ icon: endIcon, zIndexOffset: 500 }}).addTo(map);
}} else {{
map.setView([0, 0], 2);
}}
@@ -652,9 +667,6 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
// Render Elevation Chart if data is available
if ({has_elevation_chart}) {{
var ctx = document.getElementById('elevationChart').getContext('2d');
var distanceData = {json.dumps(distance_data)};
var altitudeData = {json.dumps(altitude_data)};
var latlngData = {json.dumps(latlng_data)};
var sportEmoji = "{sport_emoji}";
var hoverIcon = L.divIcon({{
@@ -840,14 +852,67 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
</script>
</body>
</html>"""
return [
TextContent(type="text", text=html_content.strip()),
_resource(
f"internal://activities/{activity_id}/map",
{"polyline": polyline},
),
]
return html_content.strip()
except Exception as e:
error_msg = f"Error rendering activity map: {str(e)}"
if ctx:
await ctx.error(error_msg)
raise RuntimeError(error_msg)
@mcp.tool()
async def get_activity_map(ctx: Context, activity_id: int) -> CallToolResult:
"""
Get an interactive map visualization for a specific Strava activity by its ID.
:param activity_id: The numeric ID of the Strava activity.
Returns the Resource URI where the interactive app is served.
"""
try:
# Check early that activity has GPS data so we can warn the user/LLM
await ctx.info(f"Checking GPS data for activity (ID: {activity_id})...")
activity = await strava.get_activity(activity_id)
activity_map = activity.get("map", {})
polyline = activity_map.get("polyline") or activity_map.get(
"summary_polyline"
)
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 CallToolResult(
content=[TextContent(type="text", text=warning_msg)],
isError=True,
)
resource_uri = f"strava://activity/{activity_id}/map"
return CallToolResult(
content=[
TextContent(
type="text",
text=f"Die interaktive Karte wurde vorbereitet. Du findest sie unter der URI: {resource_uri}",
),
],
_meta={"ui": {"resourceUri": resource_uri}},
)
except Exception as e:
error_msg = f"Error preparing 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,
)
@mcp.resource("strava://activity/{activity_id}/map", mime_type="text/html")
async def get_activity_map_resource(activity_id: int) -> str:
"""HTML map resource for a specific Strava activity."""
return await _render_activity_map_html(activity_id)
@mcp.resource("strava://activity/last/map", mime_type="text/html")
async def get_last_activity_map_resource() -> str:
"""HTML map resource for the most recent Strava activity."""
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"]
return await _render_activity_map_html(last_id)
except Exception as e:
return f"<html><body><p>Error loading last activity: {str(e)}</p></body></html>"
+36 -17
View File
@@ -15,6 +15,16 @@ class MockMCP:
return decorator
def resource(self, path, **kwargs):
if not hasattr(self, "resources"):
self.resources = {}
def decorator(func):
self.resources[path] = func
return func
return decorator
@pytest.mark.asyncio
async def test_get_activity_map_success():
@@ -46,21 +56,29 @@ 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
# First block is the HTML text content
assert result[0].type == "text"
assert "<!DOCTYPE html>" in result[0].text
assert "a~l~FqzbwOq}@it@..." in result[0].text
assert "unpkg.com/leaflet" in result[0].text
assert "latlngData" in result[0].text
assert "[47.1, 9.1]" in result[0].text
assert 'sportEmoji = "🚴"' in result[0].text
assert "L.control.layers" in result[0].text
assert "satellite" in result[0].text
assert result.isError is not True
assert result.meta == {"ui": {"resourceUri": "strava://activity/12345/map"}}
# Second block is the internal resource
assert result[1].type == "resource"
assert "internal://activities/12345/map" in str(result[1].resource.uri)
content = result.content
assert len(content) == 1
assert content[0].type == "text"
assert "Die interaktive Karte wurde vorbereitet" in content[0].text
# Now fetch and test the actual HTML from the registered resource handler
assert "strava://activity/{activity_id}/map" in mcp.resources
resource_handler = mcp.resources["strava://activity/{activity_id}/map"]
html_text = await resource_handler(12345)
assert isinstance(html_text, str)
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
@@ -85,6 +103,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