Files
AICycling_mcp/main.py

751 lines
30 KiB
Python

#!/usr/bin/env python3
"""
Cycling Workout Analyzer with Pydantic AI and MCP Server Integration
A Python app that uses Pydantic AI with MCP tools to analyze cycling workouts
"""
import os
import json
import asyncio
import shutil
import logging
import subprocess
import tempfile
import time
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional, Union
from pathlib import Path
import aiohttp
import yaml
from dataclasses import dataclass
# Pydantic AI imports
try:
from pydantic_ai import Agent
PYDANTIC_AI_AVAILABLE = True
except ImportError:
PYDANTIC_AI_AVAILABLE = False
print("Pydantic AI not available. Install with: pip install pydantic-ai")
# MCP Protocol imports for direct connection
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
print("MCP not available. Install with: pip install mcp")
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class Config:
"""Application configuration"""
openrouter_api_key: str
openrouter_model: str = "deepseek/deepseek-r1-0528:free"
garth_token: str = "" # GARTH_TOKEN for authentication
garth_mcp_server_path: str = "uvx" # Use uvx to run garth-mcp-server
rules_file: str = "rules.yaml"
templates_dir: str = "templates"
class GarminMCPTools:
"""MCP Tools interface for Pydantic AI"""
def __init__(self, garth_token: str, server_path: str):
self.garth_token = garth_token
self.server_path = server_path
self.server_available = False
self._session: Optional[ClientSession] = None
self._client_context = None
self._read_stream = None
self._write_stream = None
self._connection_timeout = 30
# Known tools (workaround for hanging list_tools)
self.available_tools = [
{"name": "user_profile", "description": "Get user profile information"},
{"name": "user_settings", "description": "Get user settings and preferences"},
{"name": "daily_sleep", "description": "Get daily sleep summary data"},
{"name": "daily_steps", "description": "Get daily steps data"},
{"name": "daily_hrv", "description": "Get heart rate variability data"},
{"name": "get_activities", "description": "Get list of activities"},
{"name": "get_activity_details", "description": "Get detailed activity information"},
{"name": "get_body_composition", "description": "Get body composition data"},
{"name": "get_respiration_data", "description": "Get respiration data"},
{"name": "get_blood_pressure", "description": "Get blood pressure readings"}
]
async def _get_server_params(self):
"""Get server parameters for MCP connection"""
env = os.environ.copy()
env['GARTH_TOKEN'] = self.garth_token
server_command = shutil.which("garth-mcp-server")
if not server_command:
logger.error("Could not find 'garth-mcp-server' in your PATH.")
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):
"""Connect to MCP server"""
if self._session and self.server_available:
return True
if not MCP_AVAILABLE:
logger.error("MCP library not available")
return False
try:
logger.info("Connecting to Garth MCP server...")
server_params = await self._get_server_params()
self._client_context = stdio_client(server_params)
streams = await self._client_context.__aenter__()
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
await asyncio.sleep(1.0)
self._session = ClientSession(self._read_stream, self._write_stream)
try:
await asyncio.wait_for(self._session.initialize(), timeout=self._connection_timeout)
logger.info("✓ MCP session initialized successfully")
# Skip the hanging list_tools() call - we'll use our known tools list
logger.info("Skipping list_tools() call (known to hang), using predefined tools")
self.server_available = True
return True
except asyncio.TimeoutError:
logger.error("MCP session initialization timed out")
await self.disconnect()
return False
except Exception as e:
logger.error(f"Failed to connect to MCP server: {e}")
await self.disconnect()
return False
async def _log_stderr(self, stderr_stream):
"""Log stderr from server"""
try:
async for line in stderr_stream:
logger.debug(f"[garth-mcp-server] {line.decode().strip()}")
except Exception:
pass
async def disconnect(self):
"""Disconnect from MCP server"""
if self._client_context:
try:
await self._client_context.__aexit__(None, None, None)
except Exception as e:
logger.error(f"Disconnect error: {e}")
self._session = None
self.server_available = False
self._client_context = None
async def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Any:
"""Call MCP tool with timeout"""
if not self.server_available or not self._session:
# Return mock data if no connection
return self._get_mock_tool_response(tool_name, arguments)
try:
logger.info(f"Calling MCP tool: {tool_name}")
result = await asyncio.wait_for(
self._session.call_tool(tool_name, arguments or {}),
timeout=self._connection_timeout
)
logger.info(f"✓ Tool call '{tool_name}' successful")
return result
except asyncio.TimeoutError:
logger.error(f"Tool call '{tool_name}' timed out, using mock data")
return self._get_mock_tool_response(tool_name, arguments)
except Exception as e:
logger.error(f"Tool call '{tool_name}' failed: {e}, using mock data")
return self._get_mock_tool_response(tool_name, arguments)
def _get_mock_tool_response(self, tool_name: str, arguments: Dict[str, Any] = None):
"""Generate mock responses for testing"""
if tool_name == "get_activities":
limit = arguments.get("limit", 10) if arguments else 10
activities = []
for i in range(min(limit, 5)):
activities.append({
"activityId": f"1234567890{i}",
"activityName": f"Cycling Workout {i+1}",
"startTimeLocal": f"2024-01-{15+i:02d}T08:00:00",
"activityType": {"typeKey": "cycling"},
"distance": 25000 + (i * 2000),
"duration": 3600 + (i * 300),
"averageSpeed": 6.94 + (i * 0.1),
"maxSpeed": 12.5 + (i * 0.2),
"elevationGain": 350 + (i * 25),
"averageHR": 145 + (i * 2),
"maxHR": 172 + (i * 3),
"averagePower": 180 + (i * 10),
"maxPower": 420 + (i * 15),
"normalizedPower": 185 + (i * 8),
"calories": 890 + (i * 50),
"averageCadence": 85 + (i * 2),
"maxCadence": 110 + (i * 1)
})
class MockResult:
def __init__(self, data):
self.content = [MockContent(json.dumps(data))]
class MockContent:
def __init__(self, text):
self.text = text
return MockResult(activities)
elif tool_name == "user_profile":
profile_data = {
"displayName": "Test Cyclist",
"fullName": "Test User",
"email": "test@example.com",
"profileImageUrl": None
}
class MockResult:
def __init__(self, data):
self.content = [MockContent(json.dumps(data))]
class MockContent:
def __init__(self, text):
self.text = text
return MockResult(profile_data)
# Default empty response
class MockResult:
def __init__(self):
self.content = [MockContent("{}")]
class MockContent:
def __init__(self, text):
self.text = text
return MockResult()
class PydanticAIAnalyzer:
"""Pydantic AI powered cycling analyzer"""
def __init__(self, config: Config):
self.config = config
self.garmin_tools = GarminMCPTools(config.garth_token, config.garth_mcp_server_path)
if not PYDANTIC_AI_AVAILABLE:
raise Exception("Pydantic AI not available. Install with: pip install pydantic-ai")
# Set environment variables for OpenRouter
os.environ['OPENROUTER_API_KEY'] = config.openrouter_api_key
os.environ['OPENAI_BASE_URL'] = "https://openrouter.ai/api/v1"
os.environ['OPENAI_DEFAULT_HEADERS'] = json.dumps({
"HTTP-Referer": "https://github.com/cycling-analyzer",
"X-Title": "Cycling Workout Analyzer"
})
# Create agent with OpenRouter model using string identifier
# Pydantic AI supports OpenRouter via "openrouter:" prefix
model_name = f"openrouter:{config.openrouter_model}"
self.agent = Agent(
model=model_name,
system_prompt="""You are an expert cycling coach with access to comprehensive Garmin Connect data.
You analyze cycling workouts, provide performance insights, and give actionable training recommendations.
Use the available tools to gather detailed workout data and provide comprehensive analysis.""",
)
# Register MCP tools as Pydantic AI tools
self._register_garmin_tools()
def _register_garmin_tools(self):
"""Register Garmin MCP tools as Pydantic AI tools"""
from pydantic_ai import RunContext
@self.agent.tool
async def get_garmin_activities(ctx: RunContext[None], limit: int = 10) -> str:
"""Get recent Garmin activities"""
try:
result = await self.garmin_tools.call_tool("get_activities", {"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 json.dumps(activities, indent=2)
return "No activities data available"
except Exception as e:
logger.error(f"Error getting activities: {e}")
return f"Error retrieving activities: {e}"
@self.agent.tool
async def get_garmin_user_profile(ctx: RunContext[None]) -> str:
"""Get Garmin user profile information"""
try:
result = await self.garmin_tools.call_tool("user_profile")
if result and hasattr(result, 'content'):
for content in result.content:
if hasattr(content, 'text'):
return content.text
return "No profile data available"
except Exception as e:
logger.error(f"Error getting profile: {e}")
return f"Error retrieving profile: {e}"
@self.agent.tool
async def get_garmin_activity_details(ctx: RunContext[None], activity_id: str) -> str:
"""Get detailed information about a specific Garmin activity"""
try:
result = await self.garmin_tools.call_tool("get_activity_details", {"activity_id": activity_id})
if result and hasattr(result, 'content'):
for content in result.content:
if hasattr(content, 'text'):
return content.text
return "No activity details available"
except Exception as e:
logger.error(f"Error getting activity details: {e}")
return f"Error retrieving activity details: {e}"
@self.agent.tool
async def get_garmin_hrv_data(ctx: RunContext[None]) -> str:
"""Get heart rate variability data from Garmin"""
try:
result = await self.garmin_tools.call_tool("daily_hrv")
if result and hasattr(result, 'content'):
for content in result.content:
if hasattr(content, 'text'):
return content.text
return "No HRV data available"
except Exception as e:
logger.error(f"Error getting HRV data: {e}")
return f"Error retrieving HRV data: {e}"
@self.agent.tool
async def get_garmin_sleep_data(ctx: RunContext[None]) -> str:
"""Get sleep data from Garmin"""
try:
result = await self.garmin_tools.call_tool("daily_sleep")
if result and hasattr(result, 'content'):
for content in result.content:
if hasattr(content, 'text'):
return content.text
return "No sleep data available"
except Exception as e:
logger.error(f"Error getting sleep data: {e}")
return f"Error retrieving sleep data: {e}"
async def initialize(self):
"""Initialize the analyzer and connect to MCP server"""
logger.info("Initializing Pydantic AI analyzer...")
try:
# Add timeout to the entire connection process
success = await asyncio.wait_for(
self.garmin_tools.connect(),
timeout=45 # 45 second timeout
)
if success:
logger.info("✓ MCP server connected successfully")
else:
logger.warning("MCP server connection failed - will use mock data")
except asyncio.TimeoutError:
logger.error("MCP connection timed out after 45 seconds - using mock data")
success = False
except Exception as e:
logger.error(f"MCP connection error: {e} - using mock data")
success = False
# Add debug info
logger.info("Initialization completed successfully")
return True
async def cleanup(self):
"""Cleanup resources"""
await self.garmin_tools.disconnect()
logger.info("Cleanup completed")
async def analyze_last_workout(self, training_rules: str) -> str:
"""Analyze the last cycling workout using Pydantic AI"""
logger.info("Analyzing last workout with Pydantic AI...")
prompt = f"""
Please analyze my most recent cycling workout. Use the get_garmin_activities tool to fetch my recent activities,
then focus on the latest cycling workout.
My training rules and goals:
{training_rules}
Please provide:
1. Overall assessment of the workout
2. How well it aligns with my rules and goals
3. Areas for improvement
4. Specific feedback on power, heart rate, duration, and intensity
5. Recovery recommendations
6. Comparison with typical performance metrics
Use additional Garmin tools (like HRV or sleep data) if they would provide relevant context.
"""
try:
result = await self.agent.run(prompt)
return result.data
except Exception as e:
logger.error(f"Error in workout analysis: {e}")
return f"Error analyzing workout: {e}"
async def suggest_next_workout(self, training_rules: str) -> str:
"""Suggest next workout using Pydantic AI"""
logger.info("Generating workout suggestion with Pydantic AI...")
prompt = f"""
Please suggest my next cycling workout based on my recent training history. Use the get_garmin_activities tool
to get my recent activities and analyze the training pattern.
My training rules and goals:
{training_rules}
Please provide:
1. Analysis of my recent training pattern
2. Identified gaps or imbalances in my training
3. Specific workout recommendation for my next session
4. Target zones (power, heart rate, duration)
5. Rationale for the recommendation based on recent performance
6. Alternative options if weather/time constraints exist
7. How this fits into my overall training progression
Use additional tools like HRV or sleep data to inform recovery status and workout readiness.
"""
try:
result = await self.agent.run(prompt)
return result.data
except Exception as e:
logger.error(f"Error in workout suggestion: {e}")
return f"Error suggesting workout: {e}"
async def enhanced_analysis(self, analysis_type: str, training_rules: str) -> str:
"""Perform enhanced analysis using Pydantic AI with all available tools"""
logger.info(f"Performing enhanced {analysis_type} analysis...")
prompt = f"""
Please perform a comprehensive {analysis_type} analysis of my cycling training data.
Use all available Garmin tools to gather relevant data including:
- Recent activities and workout details
- User profile information
- Heart rate variability data
- Sleep quality data
- Any other relevant metrics
My training rules and goals:
{training_rules}
Focus your {analysis_type} analysis on:
1. **Data Gathering**: Use multiple tools to get comprehensive data
2. **Performance Analysis**: Analyze power, heart rate, training load, and recovery metrics
3. **Training Periodization**: Consider my training phase and progression
4. **Actionable Recommendations**: Provide specific, measurable guidance
5. **Risk Assessment**: Identify any signs of overtraining or injury risk
Be thorough and use multiple data points to support your recommendations.
"""
try:
result = await self.agent.run(prompt)
return result.data
except Exception as e:
logger.error(f"Error in enhanced analysis: {e}")
return f"Error in {analysis_type} analysis: {e}"
class TemplateManager:
"""Manages prompt templates (kept for compatibility)"""
def __init__(self, templates_dir: str):
self.templates_dir = Path(templates_dir)
self.templates_dir.mkdir(exist_ok=True)
def list_templates(self) -> List[str]:
"""List available templates"""
return [f.name for f in self.templates_dir.glob("*.txt")]
class RulesManager:
"""Manages training rules and goals"""
def __init__(self, rules_file: str):
self.rules_file = Path(rules_file)
self._create_default_rules()
def _create_default_rules(self):
"""Create default rules file if it doesn't exist"""
if not self.rules_file.exists():
default_rules = {
"training_goals": [
"Improve FTP (Functional Threshold Power)",
"Build endurance for 100km rides",
"Maintain consistent training 4-5x per week"
],
"power_zones": {
"zone_1_active_recovery": "< 142W",
"zone_2_endurance": "142-162W",
"zone_3_tempo": "163-180W",
"zone_4_lactate_threshold": "181-196W",
"zone_5_vo2_max": "197-224W",
"zone_6_anaerobic": "> 224W"
},
"heart_rate_zones": {
"zone_1": "< 129 bpm",
"zone_2": "129-146 bpm",
"zone_3": "147-163 bpm",
"zone_4": "164-181 bpm",
"zone_5": "> 181 bpm"
},
"weekly_structure": {
"easy_rides": "60-70% of weekly volume",
"moderate_rides": "20-30% of weekly volume",
"hard_rides": "5-15% of weekly volume"
},
"recovery_rules": [
"At least 1 full rest day per week",
"Easy spin after hard workouts",
"Listen to body - skip workout if overly fatigued"
],
"workout_preferences": [
"Prefer morning rides when possible",
"Include variety - not just steady state",
"Focus on consistency over peak performance"
]
}
with open(self.rules_file, 'w') as f:
yaml.dump(default_rules, f, default_flow_style=False)
logger.info(f"Created default rules file: {self.rules_file}")
def get_rules(self) -> str:
"""Get rules as formatted string"""
with open(self.rules_file, 'r') as f:
rules = yaml.safe_load(f)
return yaml.dump(rules, default_flow_style=False)
class CyclingAnalyzer:
"""Main application class using Pydantic AI"""
def __init__(self, config: Config):
self.config = config
self.analyzer = PydanticAIAnalyzer(config)
self.templates = TemplateManager(config.templates_dir)
self.rules = RulesManager(config.rules_file)
async def initialize(self):
"""Initialize the application"""
logger.info("Initializing Pydantic AI Cycling Analyzer...")
result = await self.analyzer.initialize()
logger.info("Application initialization complete")
return result
async def cleanup(self):
"""Cleanup resources"""
await self.analyzer.cleanup()
logger.info("Application cleanup completed")
async def analyze_last_workout(self):
"""Analyze the last cycling workout"""
rules_text = self.rules.get_rules()
return await self.analyzer.analyze_last_workout(rules_text)
async def suggest_next_workout(self):
"""Suggest next workout based on recent activities"""
rules_text = self.rules.get_rules()
return await self.analyzer.suggest_next_workout(rules_text)
async def enhanced_analysis(self, analysis_type: str):
"""Perform enhanced analysis using all available tools"""
rules_text = self.rules.get_rules()
return await self.analyzer.enhanced_analysis(analysis_type, rules_text)
async def list_available_tools(self):
"""List available Garmin tools"""
return self.analyzer.garmin_tools.available_tools
async def run(self):
"""Main application loop"""
logger.info("Starting Cycling Workout Analyzer with Pydantic AI...")
logger.info("Calling initialize()...")
await self.initialize()
logger.info("Initialize() completed, starting main loop...")
try:
while True:
print("\n" + "="*60)
print("CYCLING WORKOUT ANALYZER (Pydantic AI + MCP)")
print("="*60)
print("1. Analyze last cycling workout")
print("2. Get next workout suggestion")
print("3. Enhanced analysis using all MCP tools")
print("4. List available MCP tools")
print("5. List available templates")
print("6. View current rules")
print("7. Exit")
print("-"*60)
choice = input("Enter your choice (1-7): ").strip()
logger.info(f"User selected option: {choice}")
try:
if choice == "1":
print("\nAnalyzing your last workout with Pydantic AI...")
analysis = await self.analyze_last_workout()
print("\n" + "="*50)
print("WORKOUT ANALYSIS (Pydantic AI)")
print("="*50)
print(analysis)
elif choice == "2":
print("\nGenerating workout suggestion with Pydantic AI...")
suggestion = await self.suggest_next_workout()
print("\n" + "="*50)
print("NEXT WORKOUT SUGGESTION (Pydantic AI)")
print("="*50)
print(suggestion)
elif choice == "3":
print("\nSelect analysis type:")
print("a) Performance trends")
print("b) Training load analysis")
print("c) Recovery assessment")
analysis_choice = input("Enter choice (a-c): ").strip().lower()
analysis_types = {
'a': 'performance trends',
'b': 'training load',
'c': 'recovery assessment'
}
if analysis_choice in analysis_types:
print(f"\nPerforming {analysis_types[analysis_choice]} analysis...")
analysis = await self.enhanced_analysis(
analysis_types[analysis_choice]
)
print(f"\n{'='*50}")
print(f"ENHANCED {analysis_types[analysis_choice].upper()} ANALYSIS")
print("="*50)
print(analysis)
else:
print("Invalid choice.")
elif choice == "4":
tools = await self.list_available_tools()
print(f"\nAvailable Garmin MCP tools:")
for tool in tools:
print(f" - {tool['name']}: {tool['description']}")
elif choice == "5":
templates = self.templates.list_templates()
print(f"\nAvailable templates in {self.config.templates_dir}:")
for template in templates:
print(f" - {template}")
elif choice == "6":
rules = self.rules.get_rules()
print(f"\nCurrent rules from {self.config.rules_file}:")
print("-"*30)
print(rules)
elif choice == "7":
print("Goodbye!")
break
else:
print("Invalid choice. Please try again.")
except Exception as e:
logger.error(f"Error: {e}")
print(f"An error occurred: {e}")
input("\nPress Enter to continue...")
finally:
await self.cleanup()
def load_config() -> Config:
"""Load configuration from environment and config files"""
# Try to load from config.yaml first
config_file = Path("config.yaml")
if config_file.exists():
with open(config_file) as f:
config_data = yaml.safe_load(f)
return Config(**config_data)
# Fall back to environment variables
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
api_key = input("Enter your OpenRouter API key: ").strip()
return Config(
openrouter_api_key=api_key,
openrouter_model=os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-r1-0528:free"),
garth_token=os.getenv("GARTH_TOKEN", ""),
garth_mcp_server_path=os.getenv("GARTH_MCP_SERVER_PATH", "uvx"),
)
def create_sample_config():
"""Create a sample config file"""
config_file = Path("config.yaml")
if not config_file.exists():
sample_config = {
"openrouter_api_key": "your_openrouter_api_key_here",
"openrouter_model": "deepseek/deepseek-r1-0528:free",
"garth_token": "your_garth_token_here",
"garth_mcp_server_path": "uvx",
"rules_file": "rules.yaml",
"templates_dir": "templates"
}
with open(config_file, 'w') as f:
yaml.dump(sample_config, f, default_flow_style=False)
print(f"Created sample config file: {config_file}")
print("Please edit it with your actual OpenRouter API key and GARTH_TOKEN.")
print("Get your GARTH_TOKEN by running: uvx garth login")
async def main():
"""Main entry point"""
# Create sample config if needed
create_sample_config()
try:
config = load_config()
analyzer = CyclingAnalyzer(config)
await analyzer.run()
except KeyboardInterrupt:
print("\nApplication interrupted by user")
except Exception as e:
logger.error(f"Application error: {e}")
if __name__ == "__main__":
asyncio.run(main())