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

This commit is contained in:
2026-05-30 17:36:30 +02:00
parent f231535f8e
commit 62ab00bb23
4 changed files with 60 additions and 76 deletions
+28 -6
View File
@@ -114,9 +114,20 @@ async def generate():
html_text = None html_text = None
for item in result: for item in result:
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text: if isinstance(item, TextContent):
html_text = item.text if "<!DOCTYPE html>" in item.text:
break html_text = item.text
break
elif '<iframe srcdoc="' in item.text:
prefix = '<iframe srcdoc="'
suffix = '" style="'
start = item.text.find(prefix)
if start != -1:
start += len(prefix)
end = item.text.find(suffix, start)
if end != -1:
html_text = item.text[start:end].replace("&quot;", '"')
break
elif ( elif (
isinstance(item, EmbeddedResource) isinstance(item, EmbeddedResource)
and item.resource.mimeType == "text/html" and item.resource.mimeType == "text/html"
@@ -181,9 +192,20 @@ async def generate():
# Find the TextContent that holds the HTML # Find the TextContent that holds the HTML
html_text = None html_text = None
for item in result: for item in result:
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text: if isinstance(item, TextContent):
html_text = item.text if "<!DOCTYPE html>" in item.text:
break html_text = item.text
break
elif '<iframe srcdoc="' in item.text:
prefix = '<iframe srcdoc="'
suffix = '" style="'
start = item.text.find(prefix)
if start != -1:
start += len(prefix)
end = item.text.find(suffix, start)
if end != -1:
html_text = item.text[start:end].replace("&quot;", '"')
break
elif ( elif (
isinstance(item, EmbeddedResource) isinstance(item, EmbeddedResource)
and item.resource.mimeType == "text/html" and item.resource.mimeType == "text/html"
+1 -1
View File
@@ -1,3 +1,3 @@
"""Strava MCP Server""" """Strava MCP Server"""
__version__ = "0.1.0" __version__ = "0.2.2"
+16 -49
View File
@@ -471,14 +471,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style> <style>
body {{ html, body {{
display: flex;
flex-direction: column;
height: 100vh;
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f9fa; background-color: #f8f9fa;
display: flex;
flex-direction: column;
overflow: hidden;
}} }}
.map-container {{ .map-container {{
position: relative; position: relative;
@@ -619,38 +621,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map); L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map);
function decodePolyline(encoded) {{ var distanceData = {json.dumps(distance_data)};
var points = []; var altitudeData = {json.dumps(altitude_data)};
var index = 0, len = encoded.length; var latlngData = {json.dumps(latlng_data)};
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 encodedPolyline = {json.dumps(polyline)};
var coordinates = decodePolyline(encodedPolyline);
var track; var track;
if (coordinates.length > 0) {{ if (latlngData.length > 0) {{
track = L.polyline(coordinates, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map); track = L.polyline(latlngData, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
map.fitBounds(track.getBounds()); map.fitBounds(track.getBounds());
// Start- und End-Marker zeichnen // Start- und End-Marker zeichnen
@@ -660,7 +637,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
iconAnchor: [9, 9], iconAnchor: [9, 9],
className: 'start-marker-emoji' className: 'start-marker-emoji'
}}); }});
L.marker(coordinates[0], {{ icon: startIcon, zIndexOffset: 500 }}).addTo(map); L.marker(latlngData[0], {{ icon: startIcon, zIndexOffset: 500 }}).addTo(map);
var endIcon = L.divIcon({{ 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>', 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>',
@@ -668,7 +645,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
iconAnchor: [11, 20], iconAnchor: [11, 20],
className: 'end-marker-emoji' className: 'end-marker-emoji'
}}); }});
L.marker(coordinates[coordinates.length - 1], {{ icon: endIcon, zIndexOffset: 500 }}).addTo(map); L.marker(latlngData[latlngData.length - 1], {{ icon: endIcon, zIndexOffset: 500 }}).addTo(map);
}} else {{ }} else {{
map.setView([0, 0], 2); map.setView([0, 0], 2);
}} }}
@@ -682,9 +659,6 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
// Render Elevation Chart if data is available // Render Elevation Chart if data is available
if ({has_elevation_chart}) {{ if ({has_elevation_chart}) {{
var ctx = document.getElementById('elevationChart').getContext('2d'); 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 sportEmoji = "{sport_emoji}";
var hoverIcon = L.divIcon({{ var hoverIcon = L.divIcon({{
@@ -870,16 +844,9 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
</script> </script>
</body> </body>
</html>""" </html>"""
return [ escaped_html = html_content.strip().replace('"', "&quot;")
_user_html_resource( iframe_content = f'<iframe srcdoc="{escaped_html}" style="width: 100%; height: 600px; border: none; border-radius: 12px;"></iframe>'
f"internal://activities/{activity_id}/map.html", return [TextContent(type="text", text=iframe_content)]
html_content.strip(),
),
_resource(
f"internal://activities/{activity_id}/map",
{"polyline": polyline},
),
]
except Exception as e: except Exception as e:
error_msg = f"Error rendering activity map: {str(e)}" error_msg = f"Error rendering activity map: {str(e)}"
await ctx.error(error_msg) await ctx.error(error_msg)
+15 -20
View File
@@ -46,27 +46,22 @@ async def test_get_activity_map_success():
get_activity_map = mcp.tools["get_activity_map"] get_activity_map = mcp.tools["get_activity_map"]
result = await get_activity_map(ctx, 12345) result = await get_activity_map(ctx, 12345)
assert len(result) == 2 assert len(result) == 1
# First block is the HTML embedded resource assert result[0].type == "text"
assert result[0].type == "resource" iframe_text = result[0].text
assert result[0].resource.mimeType == "text/html" assert iframe_text.startswith('<iframe srcdoc="')
assert "internal://activities/12345/map.html" in str(result[0].resource.uri) assert iframe_text.endswith('"></iframe>')
html_text = result[0].resource.text decoded_html = iframe_text.replace("&quot;", '"')
assert "<!DOCTYPE html>" in html_text assert "<!DOCTYPE html>" in decoded_html
assert "a~l~FqzbwOq}@it@..." in html_text assert "unpkg.com/leaflet" in decoded_html
assert "unpkg.com/leaflet" in html_text assert "latlngData" in decoded_html
assert "latlngData" in html_text assert "[47.1, 9.1]" in decoded_html
assert "[47.1, 9.1]" in html_text assert 'sportEmoji = "🚴"' in decoded_html
assert 'sportEmoji = "🚴"' in html_text assert "L.control.layers" in decoded_html
assert "L.control.layers" in html_text assert "satellite" in decoded_html
assert "satellite" in html_text assert "🟢" in decoded_html
assert "🟢" in html_text assert "🏁" in decoded_html
assert "🏁" in html_text
# Second block is the internal resource
assert result[1].type == "resource"
assert "internal://activities/12345/map" in str(result[1].resource.uri)
@pytest.mark.asyncio @pytest.mark.asyncio