#!/usr/bin/env python3 """ Fixed GarthMCPConnector class to resolve hanging issues """ import os import json import asyncio import shutil import logging from typing import Dict, List, Any, Optional from pathlib import Path # MCP Protocol imports try: from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client MCP_AVAILABLE = True except ImportError: MCP_AVAILABLE = False logger = logging.getLogger(__name__) class GarthMCPConnector: """Fixed Connector for Garmin data via Garth MCP server""" def __init__(self, garth_token: str, server_path: str): self.garth_token = garth_token self.server_path = server_path self.server_available = False self.cached_tools = [] # Cache tools to avoid repeated fetches self._session: Optional[ClientSession] = None self._client_context = None self._read_stream = None self._write_stream = None self._connection_timeout = 30 # Timeout for operations async def _get_server_params(self): """Get server parameters for MCP connection""" env = os.environ.copy() env['GARTH_TOKEN'] = self.garth_token # Find the full path to the server executable server_command = shutil.which("garth-mcp-server") if not server_command: logger.error("Could not find 'garth-mcp-server' in your PATH.") logger.error("Please ensure it is installed and accessible, e.g., via 'npm install -g garth-mcp-server'.") raise FileNotFoundError("garth-mcp-server not found") return StdioServerParameters( command="/bin/bash", args=["-c", f"exec {server_command} \"$@\" 1>&2"], capture_stderr=True, env=env, ) async def connect(self): """Start the MCP server and establish a persistent session.""" if self._session and self.server_available: return True if not MCP_AVAILABLE: logger.error("MCP library not available. Install with: pip install mcp") return False try: logger.info("Connecting to Garth MCP server...") server_params = await self._get_server_params() logger.info("Starting MCP server process...") self._client_context = stdio_client(server_params) streams = await self._client_context.__aenter__() # Handle stderr logging in background if len(streams) == 3: self._read_stream, self._write_stream, stderr_stream = streams asyncio.create_task(self._log_stderr(stderr_stream)) else: self._read_stream, self._write_stream = streams logger.info("Server process started. Waiting for it to initialize...") await asyncio.sleep(1.0) # Give server more time to start logger.info("Initializing MCP session...") self._session = ClientSession(self._read_stream, self._write_stream) # Initialize with timeout try: await asyncio.wait_for(self._session.initialize(), timeout=self._connection_timeout) except asyncio.TimeoutError: logger.error("MCP session initialization timed out") await self.disconnect() return False logger.info("Testing connection by listing tools...") # Test tools listing with timeout try: await asyncio.wait_for(self._session.list_tools(), timeout=15) logger.info("✓ Successfully connected to MCP server.") self.server_available = True return True except asyncio.TimeoutError: logger.error("Tools listing timed out - server may be unresponsive") await self.disconnect() return False except Exception as e: logger.error(f"Failed to connect to MCP server: {e}") await self.disconnect() self.server_available = False return False async def _log_stderr(self, stderr_stream): """Log stderr from the server process""" try: async for line in stderr_stream: logger.debug(f"[garth-mcp-server] {line.decode().strip()}") except Exception as e: logger.debug(f"Error reading stderr: {e}") async def disconnect(self): """Disconnect from the MCP server and clean up resources.""" logger.info("Disconnecting from MCP server...") if self._client_context: try: await self._client_context.__aexit__(None, None, None) except Exception as e: logger.error(f"Error during MCP client disconnection: {e}") self._session = None self.server_available = False self.cached_tools = [] self._client_context = None self._read_stream = None self._write_stream = None logger.info("Disconnected.") async def _ensure_connected(self): """Ensure server is available""" if not self.server_available or not self._session: return await self.connect() return True async def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Any: """Call a tool on the MCP server with timeout""" if not await self._ensure_connected(): raise Exception("MCP server not available") try: return await asyncio.wait_for( self._session.call_tool(tool_name, arguments or {}), timeout=self._connection_timeout ) except asyncio.TimeoutError: logger.error(f"Tool call '{tool_name}' timed out") raise Exception(f"Tool call '{tool_name}' timed out") except Exception as e: logger.error(f"Tool call failed: {e}") raise async def get_available_tools_info(self) -> List[Dict[str, str]]: """Get information about available MCP tools with proper timeout handling""" # Return cached tools if available if self.cached_tools: logger.debug("Returning cached tools") return self.cached_tools if not await self._ensure_connected(): logger.warning("Could not connect to MCP server") return [] try: logger.debug("Fetching tools from MCP server...") # Use timeout for the tools listing tools_result = await asyncio.wait_for( self._session.list_tools(), timeout=15 ) if not tools_result: logger.warning("No tools result received from server") return [] tools = tools_result.tools if hasattr(tools_result, 'tools') else [] logger.info(f"Retrieved {len(tools)} tools from MCP server") # Cache the tools for future use self.cached_tools = [ { "name": tool.name, "description": getattr(tool, 'description', 'No description available'), } for tool in tools ] return self.cached_tools except asyncio.TimeoutError: logger.error("Tools listing timed out after 15 seconds") # Don't cache empty result on timeout return [] except Exception as e: logger.warning(f"Could not get tools info: {e}") return [] async def get_activities_data(self, limit: int = 10) -> List[Dict[str, Any]]: """Get activities data via MCP or fallback to mock data""" if not await self._ensure_connected() or not self._session: logger.warning("No MCP session available, using mock data") return self._get_mock_activities_data(limit) try: # Try different possible tool names for getting activities possible_tools = ['get_activities', 'list_activities', 'activities', 'garmin_activities'] available_tools = await self.get_available_tools_info() for tool_name in possible_tools: if any(tool['name'] == tool_name for tool in available_tools): logger.info(f"Calling tool: {tool_name}") result = await self.call_tool(tool_name, {"limit": limit}) if result and hasattr(result, 'content'): activities = [] for content in result.content: if hasattr(content, 'text'): try: data = json.loads(content.text) if isinstance(data, list): activities.extend(data) else: activities.append(data) except json.JSONDecodeError: activities.append({"description": content.text}) return activities logger.warning("No suitable activity tool found, falling back to mock data") return self._get_mock_activities_data(limit) except Exception as e: logger.error(f"Failed to get activities via MCP: {e}") logger.warning("Falling back to mock data") return self._get_mock_activities_data(limit) def _get_mock_activities_data(self, limit: int = 10) -> List[Dict[str, Any]]: """Get mock activities data for testing""" base_activity = { "activityId": "12345678901", "activityName": "Morning Ride", "startTimeLocal": "2024-01-15T08:00:00", "activityType": {"typeKey": "cycling"}, "distance": 25000, "duration": 3600, "averageSpeed": 6.94, "maxSpeed": 12.5, "elevationGain": 350, "averageHR": 145, "maxHR": 172, "averagePower": 180, "maxPower": 420, "normalizedPower": 185, "calories": 890, "averageCadence": 85, "maxCadence": 110 } activities = [] for i in range(min(limit, 10)): activity = base_activity.copy() activity["activityId"] = str(int(base_activity["activityId"]) + i) activity["activityName"] = f"Cycling Workout {i+1}" activity["distance"] = base_activity["distance"] + (i * 2000) activity["averagePower"] = base_activity["averagePower"] + (i * 10) activity["duration"] = base_activity["duration"] + (i * 300) activities.append(activity) return activities async def get_last_cycling_workout(self) -> Optional[Dict[str, Any]]: """Get the most recent cycling workout""" activities = await self.get_activities_data(limit=50) cycling_activities = [ activity for activity in activities if self._is_cycling_activity(activity) ] return cycling_activities[0] if cycling_activities else None async def get_last_n_cycling_workouts(self, n: int = 4) -> List[Dict[str, Any]]: """Get the last N cycling workouts""" activities = await self.get_activities_data(limit=50) cycling_activities = [ activity for activity in activities if self._is_cycling_activity(activity) ] return cycling_activities[:n] def _is_cycling_activity(self, activity: Dict[str, Any]) -> bool: """Check if an activity is a cycling workout""" activity_type = activity.get('activityType', {}).get('typeKey', '').lower() activity_name = activity.get('activityName', '').lower() cycling_keywords = ['cycling', 'bike', 'ride', 'bicycle'] return ( 'cycling' in activity_type or 'bike' in activity_type or any(keyword in activity_name for keyword in cycling_keywords) ) # Test function to verify the fix async def test_fixed_connector(): """Test the fixed connector""" import yaml # Load config with open("config.yaml") as f: config_data = yaml.safe_load(f) connector = GarthMCPConnector( config_data['garth_token'], config_data['garth_mcp_server_path'] ) print("Testing fixed MCP connector...") try: # Test connection success = await connector.connect() if success: print("✓ Connection successful") # Test tools retrieval tools = await connector.get_available_tools_info() print(f"✓ Retrieved {len(tools)} tools") for tool in tools[:5]: print(f" - {tool['name']}: {tool['description']}") if len(tools) > 5: print(f" ... and {len(tools) - 5} more tools") else: print("✗ Connection failed") except Exception as e: print(f"✗ Test failed: {e}") import traceback traceback.print_exc() finally: await connector.disconnect() if __name__ == "__main__": asyncio.run(test_fixed_connector())