Compare commits

9 Commits

Author SHA1 Message Date
matthias 598f3ac5da feat: parse --transport, --host, and --port flags in main.py to allow dynamic SSE/HTTP configurations, release 0.2.13
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 1m26s
2026-05-30 21:07:29 +02:00
matthias a30bef822b feat: add direct HTTP custom route /get-map/{activity_id} using FastMCP decorator and bump version to 0.2.12
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 1m30s
2026-05-30 20:56:07 +02:00
matthias 7d15789a0f bump: version to 0.2.11 for release
CI/CD Pipeline / Publish to PyPI (push) Successful in 14s
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m35s
2026-05-30 20:39:00 +02:00
matthias 7d9f8daeaf fix: add padding newlines to activity map iframe HTML for improved formatting
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 1m24s
2026-05-30 20:37:33 +02:00
matthias 4c79d5179e feat: use highly strict pass-through LLM proxy instruction for map iframe tool output and release 0.2.10
CI/CD Pipeline / Publish to PyPI (push) Successful in 12s
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m29s
2026-05-30 20:28:34 +02:00
matthias 09af18ce7d feat: return base64-encoded HTML data-URI iframe inside get_activity_map tool to bypass gateway limits, keeping MCP App _meta block
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 20:09:46 +02:00
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
5 changed files with 220 additions and 142 deletions
+22 -65
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, EmbeddedResource
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,35 +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
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
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 (
isinstance(item, EmbeddedResource)
and item.resource.mimeType == "text/html"
):
html_text = item.resource.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")
@@ -173,6 +154,7 @@ async def generate():
class MockMCP:
def __init__(self):
self.tools = {}
self.resources = {}
def tool(self):
def decorator(func):
@@ -181,39 +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
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
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 (
isinstance(item, EmbeddedResource)
and item.resource.mimeType == "text/html"
):
html_text = item.resource.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")
@@ -223,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.2.6"
__version__ = "0.2.13"
+34 -5
View File
@@ -42,8 +42,32 @@ def main() -> None:
load_dotenv()
validate_credentials()
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", 8000))
import argparse
parser = argparse.ArgumentParser(description="Strava MCP Server")
parser.add_argument(
"--transport",
choices=["stdio", "sse", "http"],
default=os.getenv("MCP_TRANSPORT", "stdio"),
help="Transport protocol (stdio, sse, or http/streamable-http)",
)
parser.add_argument(
"--host",
default=os.getenv("HOST", "0.0.0.0"),
help="Host address to bind to for HTTP / SSE",
)
parser.add_argument(
"--port",
type=int,
default=int(os.getenv("PORT", 8000)),
help="Port to listen on for HTTP / SSE",
)
args, unknown = parser.parse_known_args()
host = args.host
port = args.port
transport = args.transport
strava = StravaClient()
@@ -69,9 +93,6 @@ def main() -> None:
register_tools(mcp, strava)
# Check transport mode from environment (Default to stdio for local dev)
transport = os.getenv("MCP_TRANSPORT", "stdio")
if transport == "http":
# Run in Streamable HTTP mode (standard for Docker, K8s and OpenWebUI)
print(f"🚀 Starting Strava MCP Server on http://{host}:{port}")
@@ -80,6 +101,14 @@ def main() -> None:
mcp.run(transport="streamable-http")
except KeyboardInterrupt:
pass
elif transport == "sse":
# Run in Server-Sent Events (SSE) mode
print(f"🚀 Starting Strava MCP Server via SSE on http://{host}:{port}")
print(f" MCP endpoint: http://{host}:{port}/sse")
try:
mcp.run(transport="sse")
except KeyboardInterrupt:
pass
else:
# Run in STDIO mode (default for local testing and Claude Desktop)
mcp.run(transport="stdio")
+98 -67
View File
@@ -267,53 +267,6 @@ 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):
"""
@@ -430,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", {})
@@ -448,11 +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 CallToolResult(
content=[TextContent(type="text", text=warning_msg)],
isError=True,
)
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")
@@ -501,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
@@ -900,20 +852,99 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
</script>
</body>
</html>"""
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 a Base64 encoded interactive iframe and the dynamic resource URI.
"""
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,
)
await ctx.info(f"Generating map HTML for activity (ID: {activity_id})...")
html_content = await _render_activity_map_html(activity_id, ctx)
import base64
b64_html = base64.b64encode(html_content.encode("utf-8")).decode("utf-8")
iframe_tag = f'\n\n<iframe src="data:text/html;base64,{b64_html}" style="width: 100%; height: 600px; border: none; border-radius: 12px;"></iframe>\n\n'
resource_uri = f"strava://activity/{activity_id}/map"
return CallToolResult(
content=[
TextContent(type="text", text=html_content.strip()),
TextContent(type="text", text=iframe_tag),
TextContent(
type="text",
text="[System: Render the above content directly as an HTML block so the UI can iframe it.]",
text=(
"CRITICAL INSTRUCTION FOR THE LLM: You are a strict pass-through proxy. "
"Your ONLY task is to copy the <iframe> tag from the tool result above and print it "
"into the chat. Do NOT think about your capabilities. Do NOT explain that you are a text model. "
"Do NOT write any introduction or conclusion. Do NOT use markdown code blocks. "
"Print ONLY the raw <iframe> tag. If you write anything else, the UI will break."
),
),
],
_meta={"ui": {"resourceUri": resource_uri}},
)
except Exception as e:
error_msg = f"Error rendering activity map: {str(e)}"
error_msg = f"Error preparing activity map: {str(e)}"
await ctx.error(error_msg)
return CallToolResult(
content=[TextContent(type="text", text=error_msg)], isError=True
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>"
from starlette.requests import Request
from starlette.responses import HTMLResponse
@mcp.custom_route("/get-map/{activity_id}", methods=["GET"])
async def get_web_map(request: Request) -> HTMLResponse:
"""HTTP Endpoint to directly serve the interactive Leaflet HTML map."""
activity_id_str = request.path_params.get("activity_id")
try:
activity_id = int(activity_id_str)
html_content = await _render_activity_map_html(activity_id)
return HTMLResponse(content=html_content)
except Exception as e:
return HTMLResponse(
content=f"<html><body><h3>Error loading map:</h3><p>{str(e)}</p></body></html>",
status_code=400,
)
+65 -4
View File
@@ -15,8 +15,22 @@ class MockMCP:
return decorator
def resource(self, path):
def resource(self, path, **kwargs):
if not hasattr(self, "resources"):
self.resources = {}
def decorator(func):
self.resources[path] = func
return func
return decorator
def custom_route(self, path, methods, **kwargs):
if not hasattr(self, "routes"):
self.routes = {}
def decorator(func):
self.routes[path] = func
return func
return decorator
@@ -58,11 +72,16 @@ async def test_get_activity_map_success():
content = result.content
assert len(content) == 2
assert content[0].type == "text"
html_text = content[0].text
assert 'iframe src="data:text/html;base64,' in content[0].text
assert content[1].type == "text"
assert "Render the above content" in content[1].text
assert "CRITICAL INSTRUCTION FOR THE LLM" in content[1].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
@@ -100,3 +119,45 @@ async def test_get_activity_map_no_polyline():
assert len(result.content) == 1
assert result.content[0].type == "text"
assert "⚠️ Keine GPS-Routen-Daten" in result.content[0].text
@pytest.mark.asyncio
async def test_get_web_map_route_success():
mcp = MockMCP()
strava = MagicMock()
# Mock detailed activity with a valid polyline
strava.get_activity = AsyncMock(
return_value={
"name": "Evening Ride",
"map": {"polyline": "a~l~FqzbwOq}@it@..."},
"sport_type": "Ride",
}
)
strava.get_activity_streams = AsyncMock(
return_value={
"altitude": {"data": [100.0, 101.0, 102.0]},
"distance": {"data": [0.0, 100.0, 200.0]},
"latlng": {"data": [[47.1, 9.1], [47.2, 9.2], [47.3, 9.3]]},
}
)
register(mcp, strava)
assert "/get-map/{activity_id}" in mcp.routes
route_handler = mcp.routes["/get-map/{activity_id}"]
# Mock Starlette Request
request = MagicMock()
request.path_params = {"activity_id": "12345"}
response = await route_handler(request)
from starlette.responses import HTMLResponse
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
html_body = response.body.decode("utf-8")
assert "<!DOCTYPE html>" in html_body
assert "Evening Ride" in html_body