mirror of
https://github.com/sstent/AICycling_mcp.git
synced 2026-04-04 12:03:16 +00:00
sync
This commit is contained in:
848
main.py
Normal file
848
main.py
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cycling Workout Analyzer with Garth MCP Server Integration
|
||||||
|
A Python app that uses OpenRouter AI and Garmin data via MCP to analyze cycling workouts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
# MCP Protocol imports
|
||||||
|
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 OpenRouterClient:
|
||||||
|
"""Client for OpenRouter AI API"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.base_url = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
async def generate_response(self, prompt: str, available_tools: List[Dict] = None) -> str:
|
||||||
|
"""Generate AI response from prompt, optionally with MCP tools available"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://github.com/your-username/cycling-analyzer",
|
||||||
|
"X-Title": "Cycling Workout Analyzer"
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
|
||||||
|
# Add tool information if available
|
||||||
|
if available_tools:
|
||||||
|
tool_info = "\n\nAvailable Garmin data tools:\n"
|
||||||
|
for tool in available_tools:
|
||||||
|
tool_info += f"- {tool['name']}: {tool.get('description', 'No description')}\n"
|
||||||
|
messages[0]["content"] += tool_info
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=payload
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
raise Exception(f"OpenRouter API error: {response.status} - {error_text}")
|
||||||
|
|
||||||
|
class GarthMCPConnector:
|
||||||
|
"""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 = None # Persistent MCP session
|
||||||
|
self.server_params = None # Server parameters for reconnection
|
||||||
|
self._connected = False # Connection status
|
||||||
|
|
||||||
|
async def _get_server_params(self):
|
||||||
|
"""Get server parameters for MCP connection"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['GARTH_TOKEN'] = self.garth_token
|
||||||
|
|
||||||
|
return StdioServerParameters(
|
||||||
|
command=self.server_path,
|
||||||
|
args=["garth-mcp-server"],
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_with_session(self, operation_func):
|
||||||
|
"""Execute an operation with a fresh MCP session"""
|
||||||
|
if not MCP_AVAILABLE:
|
||||||
|
raise Exception("MCP library not available. Install with: pip install mcp")
|
||||||
|
|
||||||
|
server_params = await self._get_server_params()
|
||||||
|
|
||||||
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
|
session = ClientSession(read_stream, write_stream)
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
# Execute the operation
|
||||||
|
result = await operation_func(session)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Test connection to MCP server"""
|
||||||
|
try:
|
||||||
|
await self._execute_with_session(lambda session: session.list_tools())
|
||||||
|
self.server_available = True
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to MCP server: {e}")
|
||||||
|
self.server_available = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect - no persistent connection to cleanup"""
|
||||||
|
self.server_available = False
|
||||||
|
self.cached_tools = [] # Clear cache
|
||||||
|
|
||||||
|
async def _ensure_connected(self):
|
||||||
|
"""Ensure server is available"""
|
||||||
|
if not self.server_available:
|
||||||
|
return await self.connect()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def start_mcp_server(self):
|
||||||
|
"""Start the Garth MCP server and initialize session"""
|
||||||
|
if not MCP_AVAILABLE:
|
||||||
|
logger.warning("MCP library not available. Install with: pip install mcp")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.server_available:
|
||||||
|
return True # Already confirmed available
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create environment with Garth token
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['GARTH_TOKEN'] = self.garth_token
|
||||||
|
|
||||||
|
# Start the MCP server using uvx
|
||||||
|
server_params = StdioServerParameters(
|
||||||
|
command=self.server_path,
|
||||||
|
args=["garth-mcp-server"],
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Starting Garth MCP server...")
|
||||||
|
|
||||||
|
# Use the stdio_client context manager - this must be done in the same async context
|
||||||
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
|
# Create the client session
|
||||||
|
session = ClientSession(read_stream, write_stream)
|
||||||
|
|
||||||
|
# Initialize the session
|
||||||
|
result = await session.initialize()
|
||||||
|
logger.info("MCP server initialized successfully")
|
||||||
|
|
||||||
|
# Get available tools and resources
|
||||||
|
try:
|
||||||
|
tools_result = await session.list_tools()
|
||||||
|
self.tools = tools_result.tools if tools_result else []
|
||||||
|
|
||||||
|
resources_result = await session.list_resources()
|
||||||
|
self.resources = resources_result.resources if resources_result else []
|
||||||
|
|
||||||
|
logger.info(f"Available tools: {[tool.name for tool in self.tools]}")
|
||||||
|
logger.info(f"Available resources: {[resource.name for resource in self.resources]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not list tools/resources: {e}")
|
||||||
|
self.tools = []
|
||||||
|
self.resources = []
|
||||||
|
|
||||||
|
# Mark server as available
|
||||||
|
self.server_available = True
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start MCP server: {e}")
|
||||||
|
logger.error("Make sure uvx is installed and garth-mcp-server is available")
|
||||||
|
logger.error("Try installing uvx with: pip install uv")
|
||||||
|
logger.error("Then get your GARTH_TOKEN with: uvx garth login")
|
||||||
|
logger.error(f"Current server path: {self.server_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def ensure_server_available(self):
|
||||||
|
"""Ensure MCP server is available"""
|
||||||
|
return await self._ensure_connected()
|
||||||
|
|
||||||
|
|
||||||
|
async def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Any:
|
||||||
|
"""Call a tool on the MCP server"""
|
||||||
|
if not await self._ensure_connected():
|
||||||
|
raise Exception("MCP server not available")
|
||||||
|
|
||||||
|
async def _call_tool(session):
|
||||||
|
return await session.call_tool(tool_name, arguments or {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_with_session(_call_tool)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Tool call failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
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 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']
|
||||||
|
|
||||||
|
for tool_name in possible_tools:
|
||||||
|
if any(tool.name == tool_name for tool in self.tools):
|
||||||
|
result = await self.call_tool(tool_name, {"limit": limit})
|
||||||
|
if result and hasattr(result, 'content'):
|
||||||
|
# Parse the result based on MCP response format
|
||||||
|
activities = []
|
||||||
|
for content in result.content:
|
||||||
|
if hasattr(content, 'text'):
|
||||||
|
# Try to parse as JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(content.text)
|
||||||
|
if isinstance(data, list):
|
||||||
|
activities.extend(data)
|
||||||
|
else:
|
||||||
|
activities.append(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If not JSON, treat as text description
|
||||||
|
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, # meters
|
||||||
|
"duration": 3600, # seconds
|
||||||
|
"averageSpeed": 6.94, # m/s
|
||||||
|
"maxSpeed": 12.5, # m/s
|
||||||
|
"elevationGain": 350, # meters
|
||||||
|
"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}"
|
||||||
|
# Vary the data slightly
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Filter for cycling activities
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Filter for cycling activities
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_available_tools_info(self) -> List[Dict[str, str]]:
|
||||||
|
"""Get information about available MCP tools"""
|
||||||
|
# Return cached tools if available
|
||||||
|
if self.cached_tools:
|
||||||
|
return self.cached_tools
|
||||||
|
|
||||||
|
if not await self._ensure_connected():
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _get_tools(session):
|
||||||
|
tools_result = await session.list_tools()
|
||||||
|
tools = tools_result.tools if tools_result else []
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_with_session(_get_tools)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get tools info: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
class TemplateManager:
|
||||||
|
"""Manages prompt templates"""
|
||||||
|
|
||||||
|
def __init__(self, templates_dir: str):
|
||||||
|
self.templates_dir = Path(templates_dir)
|
||||||
|
self.templates_dir.mkdir(exist_ok=True)
|
||||||
|
self._create_default_templates()
|
||||||
|
|
||||||
|
def _create_default_templates(self):
|
||||||
|
"""Create default template files if they don't exist"""
|
||||||
|
templates = {
|
||||||
|
"single_workout_analysis.txt": """
|
||||||
|
Analyze my cycling workout against my training rules and goals.
|
||||||
|
|
||||||
|
WORKOUT DATA:
|
||||||
|
{workout_data}
|
||||||
|
|
||||||
|
MY TRAINING RULES:
|
||||||
|
{rules}
|
||||||
|
|
||||||
|
You have access to additional Garmin data through MCP tools if needed.
|
||||||
|
|
||||||
|
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 my typical performance metrics
|
||||||
|
""".strip(),
|
||||||
|
|
||||||
|
"workout_recommendation.txt": """
|
||||||
|
Based on my recent cycling workouts, suggest what workout I should do next.
|
||||||
|
|
||||||
|
RECENT WORKOUTS:
|
||||||
|
{workouts_data}
|
||||||
|
|
||||||
|
MY TRAINING RULES:
|
||||||
|
{rules}
|
||||||
|
|
||||||
|
You have access to additional Garmin data and tools to analyze my fitness trends.
|
||||||
|
|
||||||
|
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 my recent performance
|
||||||
|
6. Alternative options if weather/time constraints exist
|
||||||
|
7. How this fits into my overall training progression
|
||||||
|
""".strip(),
|
||||||
|
|
||||||
|
"mcp_enhanced_analysis.txt": """
|
||||||
|
You are an expert cycling coach with access to comprehensive Garmin Connect data through MCP tools.
|
||||||
|
|
||||||
|
CONTEXT:
|
||||||
|
- User's Training Rules: {rules}
|
||||||
|
- Analysis Type: {analysis_type}
|
||||||
|
- Recent Data: {recent_data}
|
||||||
|
|
||||||
|
AVAILABLE MCP TOOLS:
|
||||||
|
{available_tools}
|
||||||
|
|
||||||
|
Please use the available MCP tools to gather additional relevant data and provide a comprehensive analysis. Focus on:
|
||||||
|
|
||||||
|
1. **Data Gathering**: Use MCP tools to get detailed workout metrics, trends, and historical data
|
||||||
|
2. **Performance Analysis**: Analyze power, heart rate, training load, and recovery metrics
|
||||||
|
3. **Training Periodization**: Consider the user's training phase and progression
|
||||||
|
4. **Actionable Recommendations**: Provide specific, measurable guidance for future workouts
|
||||||
|
5. **Risk Assessment**: Identify any signs of overtraining or injury risk
|
||||||
|
|
||||||
|
Be thorough in your analysis and use multiple data points to support your recommendations.
|
||||||
|
""".strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
for filename, content in templates.items():
|
||||||
|
template_path = self.templates_dir / filename
|
||||||
|
if not template_path.exists():
|
||||||
|
template_path.write_text(content)
|
||||||
|
logger.info(f"Created template: {template_path}")
|
||||||
|
|
||||||
|
def get_template(self, template_name: str) -> str:
|
||||||
|
"""Get template content"""
|
||||||
|
template_path = self.templates_dir / template_name
|
||||||
|
if template_path.exists():
|
||||||
|
return template_path.read_text()
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"Template not found: {template_path}")
|
||||||
|
|
||||||
|
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"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self.openrouter = OpenRouterClient(config.openrouter_api_key, config.openrouter_model)
|
||||||
|
self.garmin = GarthMCPConnector(
|
||||||
|
config.garth_token,
|
||||||
|
config.garth_mcp_server_path
|
||||||
|
)
|
||||||
|
self.templates = TemplateManager(config.templates_dir)
|
||||||
|
self.rules = RulesManager(config.rules_file)
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialize the application and connect to MCP server"""
|
||||||
|
logger.info("Initializing application and connecting to MCP server...")
|
||||||
|
success = await self.garmin.connect()
|
||||||
|
if success:
|
||||||
|
logger.info("Application initialized successfully")
|
||||||
|
else:
|
||||||
|
logger.warning("Application initialized but MCP server connection failed - will retry on demand")
|
||||||
|
return True # Always return True to allow the app to start
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
"""Cleanup resources"""
|
||||||
|
await self.garmin.disconnect()
|
||||||
|
logger.info("Application cleanup completed")
|
||||||
|
|
||||||
|
async def analyze_last_workout(self):
|
||||||
|
"""Analyze the last cycling workout"""
|
||||||
|
logger.info("Analyzing last cycling workout...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get workout data via MCP
|
||||||
|
workout = await self.garmin.get_last_cycling_workout()
|
||||||
|
|
||||||
|
if not workout:
|
||||||
|
return "No recent cycling workouts found in your Garmin data."
|
||||||
|
|
||||||
|
# Get rules
|
||||||
|
rules_text = self.rules.get_rules()
|
||||||
|
|
||||||
|
# Format workout data
|
||||||
|
workout_text = json.dumps(workout, indent=2)
|
||||||
|
|
||||||
|
# Get available tools info
|
||||||
|
available_tools = await self.garmin.get_available_tools_info()
|
||||||
|
|
||||||
|
# Get template and format prompt
|
||||||
|
template = self.templates.get_template("single_workout_analysis.txt")
|
||||||
|
prompt = template.format(workout_data=workout_text, rules=rules_text)
|
||||||
|
|
||||||
|
# Get AI analysis with tool information
|
||||||
|
analysis = await self.openrouter.generate_response(prompt, available_tools)
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing workout: {e}")
|
||||||
|
return f"Error analyzing workout: {e}"
|
||||||
|
|
||||||
|
async def suggest_next_workout(self):
|
||||||
|
"""Suggest next workout based on recent activities"""
|
||||||
|
logger.info("Analyzing recent workouts and suggesting next workout...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get last 4 workouts via MCP
|
||||||
|
workouts = await self.garmin.get_last_n_cycling_workouts(4)
|
||||||
|
|
||||||
|
if not workouts:
|
||||||
|
return "No recent cycling workouts found in your Garmin data."
|
||||||
|
|
||||||
|
# Get rules
|
||||||
|
rules_text = self.rules.get_rules()
|
||||||
|
|
||||||
|
# Format workouts data
|
||||||
|
workouts_text = json.dumps(workouts, indent=2)
|
||||||
|
|
||||||
|
# Get available tools info
|
||||||
|
available_tools = await self.garmin.get_available_tools_info()
|
||||||
|
|
||||||
|
# Get template and format prompt
|
||||||
|
template = self.templates.get_template("workout_recommendation.txt")
|
||||||
|
prompt = template.format(workouts_data=workouts_text, rules=rules_text)
|
||||||
|
|
||||||
|
# Get AI suggestion with tool information
|
||||||
|
suggestion = await self.openrouter.generate_response(prompt, available_tools)
|
||||||
|
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error suggesting workout: {e}")
|
||||||
|
return f"Error suggesting next workout: {e}"
|
||||||
|
|
||||||
|
async def mcp_enhanced_analysis(self, analysis_type: str):
|
||||||
|
"""Perform enhanced analysis using MCP tools directly"""
|
||||||
|
logger.info(f"Performing MCP-enhanced {analysis_type} analysis...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get rules
|
||||||
|
rules_text = self.rules.get_rules()
|
||||||
|
|
||||||
|
# Get recent data
|
||||||
|
recent_workouts = await self.garmin.get_last_n_cycling_workouts(7)
|
||||||
|
recent_data = json.dumps(recent_workouts[:3], indent=2) if recent_workouts else "No recent data"
|
||||||
|
|
||||||
|
# Get available tools info
|
||||||
|
available_tools_info = "\n".join([
|
||||||
|
f"- {tool['name']}: {tool['description']}"
|
||||||
|
for tool in await self.garmin.get_available_tools_info()
|
||||||
|
])
|
||||||
|
|
||||||
|
# Get enhanced template
|
||||||
|
template = self.templates.get_template("mcp_enhanced_analysis.txt")
|
||||||
|
prompt = template.format(
|
||||||
|
rules=rules_text,
|
||||||
|
analysis_type=analysis_type,
|
||||||
|
recent_data=recent_data,
|
||||||
|
available_tools=available_tools_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get AI analysis with full tool context
|
||||||
|
analysis = await self.openrouter.generate_response(
|
||||||
|
prompt,
|
||||||
|
await self.garmin.get_available_tools_info()
|
||||||
|
)
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in MCP enhanced analysis: {e}")
|
||||||
|
return f"Error in enhanced analysis: {e}"
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Main application loop"""
|
||||||
|
logger.info("Starting Cycling Workout Analyzer with Garth MCP Server...")
|
||||||
|
|
||||||
|
# Initialize MCP connection (with fallback mode)
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("CYCLING WORKOUT ANALYZER (with Garth MCP Integration)")
|
||||||
|
print("="*60)
|
||||||
|
print("1. Analyze last cycling workout")
|
||||||
|
print("2. Get next workout suggestion")
|
||||||
|
print("3. Enhanced analysis using 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()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if choice == "1":
|
||||||
|
print("\nAnalyzing your last workout...")
|
||||||
|
analysis = await self.analyze_last_workout()
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("WORKOUT ANALYSIS")
|
||||||
|
print("="*50)
|
||||||
|
print(analysis)
|
||||||
|
|
||||||
|
elif choice == "2":
|
||||||
|
print("\nAnalyzing recent workouts and generating suggestion...")
|
||||||
|
suggestion = await self.suggest_next_workout()
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("NEXT WORKOUT SUGGESTION")
|
||||||
|
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:
|
||||||
|
analysis = await self.mcp_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":
|
||||||
|
try:
|
||||||
|
tools = await self.garmin.get_available_tools_info()
|
||||||
|
print(f"\nAvailable MCP tools from Garth server:")
|
||||||
|
if tools:
|
||||||
|
for tool in tools:
|
||||||
|
print(f" - {tool['name']}: {tool['description']}")
|
||||||
|
else:
|
||||||
|
print(" No tools available or server not connected")
|
||||||
|
print(" Note: MCP server may be having startup issues.")
|
||||||
|
print(" Available Garmin Connect tools (when working):")
|
||||||
|
mock_tools = [
|
||||||
|
"user_profile - Get user profile information",
|
||||||
|
"user_settings - Get user settings and preferences",
|
||||||
|
"daily_sleep - Get daily sleep summary data",
|
||||||
|
"daily_steps - Get daily steps data",
|
||||||
|
"daily_hrv - Get heart rate variability data",
|
||||||
|
"get_activities - Get list of activities",
|
||||||
|
"get_activity_details - Get detailed activity information",
|
||||||
|
"get_body_composition - Get body composition data",
|
||||||
|
"get_respiration_data - Get respiration data",
|
||||||
|
"get_blood_pressure - Get blood pressure readings"
|
||||||
|
]
|
||||||
|
for tool in mock_tools:
|
||||||
|
print(f" - {tool}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing tools: {e}")
|
||||||
|
print(f"Error: {e}")
|
||||||
|
print(" Showing available Garmin Connect tools:")
|
||||||
|
mock_tools = [
|
||||||
|
"user_profile - Get user profile information",
|
||||||
|
"user_settings - Get user settings and preferences",
|
||||||
|
"daily_sleep - Get daily sleep summary data",
|
||||||
|
"daily_steps - Get daily steps data",
|
||||||
|
"daily_hrv - Get heart rate variability data",
|
||||||
|
"get_activities - Get list of activities",
|
||||||
|
"get_activity_details - Get detailed activity information",
|
||||||
|
"get_body_composition - Get body composition data",
|
||||||
|
"get_respiration_data - Get respiration data",
|
||||||
|
"get_blood_pressure - Get blood pressure readings"
|
||||||
|
]
|
||||||
|
for tool in mock_tools:
|
||||||
|
print(f" - {tool}")
|
||||||
|
# Add small delay to keep output visible
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
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", # Get this with: uvx garth login
|
||||||
|
"garth_mcp_server_path": "uvx", # Use uvx to run garth-mcp-server
|
||||||
|
"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())
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
aiohttp>=3.8.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
mcp>=0.1.0
|
||||||
|
|
||||||
|
# Built-in modules (no installation needed)
|
||||||
|
# asyncio
|
||||||
|
# pathlib
|
||||||
|
# dataclasses
|
||||||
|
# logging
|
||||||
|
|
||||||
|
# For direct Garth MCP server integration
|
||||||
|
# Note: You need to install and set up the garth-mcp-server separately
|
||||||
|
# Follow: https://github.com/matin/garth-mcp-server
|
||||||
29
rules.yaml
Normal file
29
rules.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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'
|
||||||
|
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'
|
||||||
|
recovery_rules:
|
||||||
|
- At least 1 full rest day per week
|
||||||
|
- Easy spin after hard workouts
|
||||||
|
- Listen to body - skip workout if overly fatigued
|
||||||
|
training_goals:
|
||||||
|
- Improve FTP (Functional Threshold Power)
|
||||||
|
- Build endurance for 100km rides
|
||||||
|
- Maintain consistent training 4-5x per week
|
||||||
|
weekly_structure:
|
||||||
|
easy_rides: 60-70% of weekly volume
|
||||||
|
hard_rides: 5-15% of weekly volume
|
||||||
|
moderate_rides: 20-30% of weekly volume
|
||||||
|
workout_preferences:
|
||||||
|
- Prefer morning rides when possible
|
||||||
|
- Include variety - not just steady state
|
||||||
|
- Focus on consistency over peak performance
|
||||||
192
setup.md
Normal file
192
setup.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Cycling Workout Analyzer Setup Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Python 3.8+** installed on your system
|
||||||
|
2. **OpenRouter API account** - Get your API key from [OpenRouter.ai](https://openrouter.ai)
|
||||||
|
3. **Garmin Connect account** with workout data
|
||||||
|
|
||||||
|
## Installation Steps
|
||||||
|
|
||||||
|
### 1. Install the Garth MCP Server
|
||||||
|
|
||||||
|
First, install the Garth MCP server that will connect to your Garmin data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the Garth MCP server
|
||||||
|
npm install -g garth-mcp-server
|
||||||
|
|
||||||
|
# Or if using pip/uv (check the repo for latest instructions)
|
||||||
|
# pip install garth-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Up the Python Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone or download the cycling analyzer files
|
||||||
|
# Install Python dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure the Application
|
||||||
|
|
||||||
|
Run the application once to generate the configuration file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a `config.yaml` file. Edit it with your credentials:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openrouter_api_key: "your_openrouter_api_key_here"
|
||||||
|
openrouter_model: "deepseek/deepseek-r1-0528:free"
|
||||||
|
garmin_email: "your_garmin_email@example.com"
|
||||||
|
garmin_password: "your_garmin_password"
|
||||||
|
garth_mcp_server_path: "garth-mcp-server" # or full path if needed
|
||||||
|
rules_file: "rules.yaml"
|
||||||
|
templates_dir: "templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Set Up Environment Variables (Alternative)
|
||||||
|
|
||||||
|
Instead of using the config file, you can set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENROUTER_API_KEY="your_api_key_here"
|
||||||
|
export GARMIN_EMAIL="your_email@example.com"
|
||||||
|
export GARMIN_PASSWORD="your_password"
|
||||||
|
export GARTH_MCP_SERVER_PATH="garth-mcp-server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Customize Your Training Rules
|
||||||
|
|
||||||
|
Edit the generated `rules.yaml` file with your specific:
|
||||||
|
- Training goals
|
||||||
|
- Power zones (based on your FTP)
|
||||||
|
- Heart rate zones
|
||||||
|
- Weekly training structure preferences
|
||||||
|
- Recovery rules
|
||||||
|
|
||||||
|
### 6. Customize Prompt Templates
|
||||||
|
|
||||||
|
Edit the template files in the `templates/` directory:
|
||||||
|
- `single_workout_analysis.txt` - For analyzing individual workouts
|
||||||
|
- `workout_recommendation.txt` - For getting next workout suggestions
|
||||||
|
- `mcp_enhanced_analysis.txt` - For enhanced analysis using MCP tools
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Basic Analysis
|
||||||
|
- Analyze your last cycling workout against your rules
|
||||||
|
- Get suggestions for your next workout based on recent training
|
||||||
|
|
||||||
|
### 2. MCP-Enhanced Analysis
|
||||||
|
- Uses the Garth MCP server to access comprehensive Garmin data
|
||||||
|
- Provides detailed performance trends, training load analysis, and recovery assessment
|
||||||
|
- The LLM has direct access to your Garmin tools and can fetch additional data as needed
|
||||||
|
|
||||||
|
### 3. Customizable
|
||||||
|
- Edit your training rules and goals
|
||||||
|
- Modify prompt templates to get the analysis style you want
|
||||||
|
- Configure different AI models through OpenRouter
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### MCP Connection Issues
|
||||||
|
- Ensure `garth-mcp-server` is properly installed and accessible
|
||||||
|
- Check that your Garmin credentials are correct
|
||||||
|
- Verify the server path in your configuration
|
||||||
|
|
||||||
|
### API Issues
|
||||||
|
- Confirm your OpenRouter API key is valid and has credits
|
||||||
|
- Check your internet connection
|
||||||
|
- Try a different model if the default one is unavailable
|
||||||
|
|
||||||
|
### No Workout Data
|
||||||
|
- Ensure you have recent cycling activities in Garmin Connect
|
||||||
|
- Check that the MCP server can authenticate with Garmin
|
||||||
|
- Verify your Garmin credentials
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cycling-analyzer/
|
||||||
|
├── main.py # Main application
|
||||||
|
├── config.yaml # Configuration file
|
||||||
|
├── rules.yaml # Your training rules and zones
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── templates/ # Prompt templates
|
||||||
|
├── single_workout_analysis.txt
|
||||||
|
├── workout_recommendation.txt
|
||||||
|
└── mcp_enhanced_analysis.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Templates
|
||||||
|
You can create additional templates for specific analysis types. The application will automatically detect `.txt` files in the templates directory. Template variables available:
|
||||||
|
- `{workout_data}` - Individual workout data
|
||||||
|
- `{workouts_data}` - Multiple workouts data
|
||||||
|
- `{rules}` - Your training rules
|
||||||
|
- `{available_tools}` - MCP tools information
|
||||||
|
|
||||||
|
### Custom Analysis Types
|
||||||
|
Add new analysis options by:
|
||||||
|
1. Creating a new template file
|
||||||
|
2. Adding the analysis logic to the `CyclingAnalyzer` class
|
||||||
|
3. Adding menu options in the main loop
|
||||||
|
|
||||||
|
### Multiple AI Models
|
||||||
|
You can experiment with different AI models through OpenRouter:
|
||||||
|
- `deepseek/deepseek-r1-0528:free` (default, free)
|
||||||
|
- `anthropic/claude-3-sonnet`
|
||||||
|
- `openai/gpt-4-turbo`
|
||||||
|
- `google/gemini-pro`
|
||||||
|
|
||||||
|
### Integration with Other Tools
|
||||||
|
The MCP architecture allows easy integration with other fitness tools and data sources. You can extend the application to work with:
|
||||||
|
- Training Peaks
|
||||||
|
- Strava (via MCP server)
|
||||||
|
- Wahoo, Polar, or other device manufacturers
|
||||||
|
- Custom training databases
|
||||||
|
|
||||||
|
### Automated Analysis
|
||||||
|
You can run the analyzer in automated mode by modifying the `run()` method to:
|
||||||
|
- Analyze workouts automatically after each session
|
||||||
|
- Generate weekly training reports
|
||||||
|
- Send recommendations via email or notifications
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
1. **After a workout**: Run option 1 to get immediate feedback on your session
|
||||||
|
2. **Planning next session**: Use option 2 to get AI-powered recommendations
|
||||||
|
3. **Weekly review**: Use option 3 for enhanced analysis of trends and patterns
|
||||||
|
4. **Adjust training**: Modify your `rules.yaml` based on insights and goals changes
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Store your credentials securely
|
||||||
|
- Consider using environment variables instead of config files for sensitive data
|
||||||
|
- The MCP server runs locally and connects directly to Garmin - no data is sent to third parties except the AI provider (OpenRouter)
|
||||||
|
|
||||||
|
## Support and Contributions
|
||||||
|
|
||||||
|
- Check the Garth MCP server repository for Garmin-specific issues
|
||||||
|
- Refer to OpenRouter documentation for API-related questions
|
||||||
|
- Customize templates and rules to match your specific training methodology
|
||||||
|
|
||||||
|
## What Makes This Unique
|
||||||
|
|
||||||
|
This application bridges three powerful technologies:
|
||||||
|
1. **Garth MCP Server** - Direct access to comprehensive Garmin data
|
||||||
|
2. **Model Context Protocol (MCP)** - Standardized way for AI to access tools and data
|
||||||
|
3. **OpenRouter** - Access to multiple state-of-the-art AI models
|
||||||
|
|
||||||
|
The AI doesn't just analyze static workout data - it can actively query your Garmin account for additional context, trends, and historical data to provide much more comprehensive and personalized recommendations.
|
||||||
19
templates/mcp_enhanced_analysis.txt
Normal file
19
templates/mcp_enhanced_analysis.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
You are an expert cycling coach with access to comprehensive Garmin Connect data through MCP tools.
|
||||||
|
|
||||||
|
CONTEXT:
|
||||||
|
- User's Training Rules: {rules}
|
||||||
|
- Analysis Type: {analysis_type}
|
||||||
|
- Recent Data: {recent_data}
|
||||||
|
|
||||||
|
AVAILABLE MCP TOOLS:
|
||||||
|
{available_tools}
|
||||||
|
|
||||||
|
Please use the available MCP tools to gather additional relevant data and provide a comprehensive analysis. Focus on:
|
||||||
|
|
||||||
|
1. **Data Gathering**: Use MCP tools to get detailed workout metrics, trends, and historical data
|
||||||
|
2. **Performance Analysis**: Analyze power, heart rate, training load, and recovery metrics
|
||||||
|
3. **Training Periodization**: Consider the user's training phase and progression
|
||||||
|
4. **Actionable Recommendations**: Provide specific, measurable guidance for future workouts
|
||||||
|
5. **Risk Assessment**: Identify any signs of overtraining or injury risk
|
||||||
|
|
||||||
|
Be thorough in your analysis and use multiple data points to support your recommendations.
|
||||||
17
templates/single_workout_analysis.txt
Normal file
17
templates/single_workout_analysis.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Analyze my cycling workout against my training rules and goals.
|
||||||
|
|
||||||
|
WORKOUT DATA:
|
||||||
|
{workout_data}
|
||||||
|
|
||||||
|
MY TRAINING RULES:
|
||||||
|
{rules}
|
||||||
|
|
||||||
|
You have access to additional Garmin data through MCP tools if needed.
|
||||||
|
|
||||||
|
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 my typical performance metrics
|
||||||
18
templates/workout_recommendation.txt
Normal file
18
templates/workout_recommendation.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Based on my recent cycling workouts, suggest what workout I should do next.
|
||||||
|
|
||||||
|
RECENT WORKOUTS:
|
||||||
|
{workouts_data}
|
||||||
|
|
||||||
|
MY TRAINING RULES:
|
||||||
|
{rules}
|
||||||
|
|
||||||
|
You have access to additional Garmin data and tools to analyze my fitness trends.
|
||||||
|
|
||||||
|
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 my recent performance
|
||||||
|
6. Alternative options if weather/time constraints exist
|
||||||
|
7. How this fits into my overall training progression
|
||||||
54
test_mcp_direct.py
Normal file
54
test_mcp_direct.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test MCP server directly
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import subprocess
|
||||||
|
import asyncio
|
||||||
|
from mcp import ClientSession, StdioServerParameters
|
||||||
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
||||||
|
async def test_mcp_direct():
|
||||||
|
# Load token from config
|
||||||
|
with open("config.yaml") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
token = config['garth_token']
|
||||||
|
|
||||||
|
# Set up environment
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['GARTH_TOKEN'] = token
|
||||||
|
|
||||||
|
# Set up server parameters
|
||||||
|
server_params = StdioServerParameters(
|
||||||
|
command="uvx",
|
||||||
|
args=["garth-mcp-server"],
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Starting MCP server test...")
|
||||||
|
try:
|
||||||
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
|
session = ClientSession(read_stream, write_stream)
|
||||||
|
print("Initializing session...")
|
||||||
|
result = await session.initialize()
|
||||||
|
print("✓ Session initialized")
|
||||||
|
|
||||||
|
print("Getting tools...")
|
||||||
|
tools_result = await session.list_tools()
|
||||||
|
tools = tools_result.tools if tools_result else []
|
||||||
|
print(f"✓ Found {len(tools)} tools")
|
||||||
|
|
||||||
|
for tool in tools[:5]: # Show first 5 tools
|
||||||
|
print(f" - {tool.name}: {getattr(tool, 'description', 'No description')}")
|
||||||
|
|
||||||
|
if len(tools) > 5:
|
||||||
|
print(f" ... and {len(tools) - 5} more tools")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_mcp_direct())
|
||||||
41
test_mcp_tools.py
Normal file
41
test_mcp_tools.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify MCP tools functionality
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import yaml
|
||||||
|
from main import GarthMCPConnector, Config
|
||||||
|
|
||||||
|
async def test_mcp_tools():
|
||||||
|
"""Test the MCP tools functionality"""
|
||||||
|
# Load config
|
||||||
|
with open("config.yaml") as f:
|
||||||
|
config_data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
config = Config(**config_data)
|
||||||
|
garmin = GarthMCPConnector(config.garth_token, config.garth_mcp_server_path)
|
||||||
|
|
||||||
|
print("Testing MCP tools retrieval...")
|
||||||
|
try:
|
||||||
|
tools = await garmin.get_available_tools_info()
|
||||||
|
print(f"Successfully retrieved {len(tools)} tools:")
|
||||||
|
for tool in tools:
|
||||||
|
print(f" - {tool['name']}: {tool['description']}")
|
||||||
|
|
||||||
|
# Test caching by calling again
|
||||||
|
print("\nTesting cached tools...")
|
||||||
|
tools2 = await garmin.get_available_tools_info()
|
||||||
|
print(f"Cached tools: {len(tools2)} tools")
|
||||||
|
|
||||||
|
if tools == tools2:
|
||||||
|
print("✓ Caching works correctly!")
|
||||||
|
else:
|
||||||
|
print("✗ Caching failed!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_mcp_tools())
|
||||||
68
test_option4.py
Normal file
68
test_option4.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test option 4 (list MCP tools) directly
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import yaml
|
||||||
|
from main import CyclingAnalyzer, Config
|
||||||
|
|
||||||
|
async def test_option4():
|
||||||
|
"""Test option 4 functionality"""
|
||||||
|
# Load config
|
||||||
|
with open("config.yaml") as f:
|
||||||
|
config_data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
config = Config(**config_data)
|
||||||
|
analyzer = CyclingAnalyzer(config)
|
||||||
|
|
||||||
|
await analyzer.initialize()
|
||||||
|
|
||||||
|
print("Testing option 4: List available MCP tools")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tools = await analyzer.garmin.get_available_tools_info()
|
||||||
|
print("Available MCP tools from Garth server:")
|
||||||
|
if tools:
|
||||||
|
for tool in tools:
|
||||||
|
print(f" - {tool['name']}: {tool['description']}")
|
||||||
|
else:
|
||||||
|
print(" No tools available or server not connected")
|
||||||
|
print(" Note: MCP server may be having startup issues.")
|
||||||
|
print(" Available Garmin Connect tools (when working):")
|
||||||
|
mock_tools = [
|
||||||
|
"user_profile - Get user profile information",
|
||||||
|
"user_settings - Get user settings and preferences",
|
||||||
|
"daily_sleep - Get daily sleep summary data",
|
||||||
|
"daily_steps - Get daily steps data",
|
||||||
|
"daily_hrv - Get heart rate variability data",
|
||||||
|
"get_activities - Get list of activities",
|
||||||
|
"get_activity_details - Get detailed activity information",
|
||||||
|
"get_body_composition - Get body composition data",
|
||||||
|
"get_respiration_data - Get respiration data",
|
||||||
|
"get_blood_pressure - Get blood pressure readings"
|
||||||
|
]
|
||||||
|
for tool in mock_tools:
|
||||||
|
print(f" - {tool}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
print(" Showing available Garmin Connect tools:")
|
||||||
|
mock_tools = [
|
||||||
|
"user_profile - Get user profile information",
|
||||||
|
"user_settings - Get user settings and preferences",
|
||||||
|
"daily_sleep - Get daily sleep summary data",
|
||||||
|
"daily_steps - Get daily steps data",
|
||||||
|
"daily_hrv - Get heart rate variability data",
|
||||||
|
"get_activities - Get list of activities",
|
||||||
|
"get_activity_details - Get detailed activity information",
|
||||||
|
"get_body_composition - Get body composition data",
|
||||||
|
"get_respiration_data - Get respiration data",
|
||||||
|
"get_blood_pressure - Get blood pressure readings"
|
||||||
|
]
|
||||||
|
for tool in mock_tools:
|
||||||
|
print(f" - {tool}")
|
||||||
|
|
||||||
|
await analyzer.cleanup()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_option4())
|
||||||
45
test_token.py
Normal file
45
test_token.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify GARTH_TOKEN validity
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
import garth
|
||||||
|
print("✓ Garth library imported successfully")
|
||||||
|
except ImportError:
|
||||||
|
print("✗ Garth library not installed")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Load token from config
|
||||||
|
try:
|
||||||
|
with open("config.yaml") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
token = config.get('garth_token')
|
||||||
|
if not token:
|
||||||
|
print("✗ No garth_token found in config.yaml")
|
||||||
|
exit(1)
|
||||||
|
print("✓ Token loaded from config.yaml")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error loading config: {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Test token
|
||||||
|
try:
|
||||||
|
print("Testing token validity...")
|
||||||
|
garth.client.loads(token)
|
||||||
|
print("✓ Token loaded successfully")
|
||||||
|
|
||||||
|
# Try to get user profile
|
||||||
|
print("Testing API access...")
|
||||||
|
user_profile = garth.UserProfile.get()
|
||||||
|
print("✓ API access successful")
|
||||||
|
print(f"User Profile: {user_profile}")
|
||||||
|
print(f"Display Name: {getattr(user_profile, 'display_name', 'N/A')}")
|
||||||
|
print(f"Full Name: {getattr(user_profile, 'full_name', 'N/A')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Token validation failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
Reference in New Issue
Block a user