import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any, cast from fastapi import FastAPI from mcp.server.fastmcp import Context, FastMCP from strava_mcp.config import StravaSettings from strava_mcp.service import StravaService # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: """Set up and tear down the Strava service for the MCP server. Args: server: The FastMCP server instance Yields: The lifespan context containing the Strava service """ # Load settings from environment variables try: # Let StravaSettings load values directly from env vars settings = StravaSettings( client_id="", # Will be loaded from environment variables client_secret="", # Will be loaded from environment variables base_url="https://www.strava.com/api/v3", # Default value ) if not settings.client_id: raise ValueError("STRAVA_CLIENT_ID environment variable is not set") if not settings.client_secret: raise ValueError("STRAVA_CLIENT_SECRET environment variable is not set") logger.info("Loaded Strava API settings") except Exception as e: logger.error(f"Failed to load Strava API settings: {str(e)}") raise # FastMCP extends FastAPI, so we can safely cast it for type checking fastapi_app = cast(FastAPI, server) # Initialize the Strava service with the FastAPI app service = StravaService(settings, fastapi_app) logger.info("Initialized Strava service") # Set up authentication routes and initialize await service.initialize() logger.info("Service initialization completed") try: yield {"service": service} finally: # Clean up resources await service.close() logger.info("Closed Strava service") # Create the MCP server mcp = FastMCP( "Strava", description="MCP server for interacting with the Strava API", lifespan=lifespan, client_id="", # Will be loaded from environment variables client_secret="", # Will be loaded from environment variables base_url="", # Will be loaded from environment variables ) @mcp.tool() async def get_user_activities( ctx: Context, before: int | None = None, after: int | None = None, page: int = 1, per_page: int = 30, ) -> list[dict]: """Get the authenticated user's activities. Args: ctx: The MCP request context before: An epoch timestamp for filtering activities before a certain time after: An epoch timestamp for filtering activities after a certain time page: Page number per_page: Number of items per page Returns: List of activities """ try: # Safely access service from context if not ctx.request_context.lifespan_context: raise ValueError("Lifespan context not available") # Cast service to StravaService to satisfy type checker service = cast(StravaService, ctx.request_context.lifespan_context.get("service")) if not service: raise ValueError("Service not available in context") activities = await service.get_activities(before, after, page, per_page) return [activity.model_dump() for activity in activities] except Exception as e: logger.error(f"Error in get_user_activities tool: {str(e)}") raise @mcp.tool() async def get_activity( ctx: Context, activity_id: int, include_all_efforts: bool = False, ) -> dict: """Get details of a specific activity. Args: ctx: The MCP request context activity_id: The ID of the activity include_all_efforts: Whether to include all segment efforts Returns: The activity details """ try: # Safely access service from context if not ctx.request_context.lifespan_context: raise ValueError("Lifespan context not available") # Cast service to StravaService to satisfy type checker service = cast(StravaService, ctx.request_context.lifespan_context.get("service")) if not service: raise ValueError("Service not available in context") activity = await service.get_activity(activity_id, include_all_efforts) return activity.model_dump() except Exception as e: logger.error(f"Error in get_activity tool: {str(e)}") raise @mcp.tool() async def get_activity_segments( ctx: Context, activity_id: int, ) -> list[dict]: """Get the segments of a specific activity. Args: ctx: The MCP request context activity_id: The ID of the activity Returns: List of segment efforts for the activity """ try: # Safely access service from context if not ctx.request_context.lifespan_context: raise ValueError("Lifespan context not available") # Cast service to StravaService to satisfy type checker service = cast(StravaService, ctx.request_context.lifespan_context.get("service")) if not service: raise ValueError("Service not available in context") segments = await service.get_activity_segments(activity_id) return [segment.model_dump() for segment in segments] except Exception as e: logger.error(f"Error in get_activity_segments tool: {str(e)}") raise