mirror of
https://github.com/sstent/AICycling_mcp.git
synced 2026-01-25 16:42:24 +00:00
workng
This commit is contained in:
463
mcp_manager.py
Normal file
463
mcp_manager.py
Normal file
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP Manager for Pydantic AI Cycling Analyzer
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import shutil
|
||||
import logging
|
||||
from typing import List, Any
|
||||
from dataclasses import dataclass
|
||||
import garth
|
||||
|
||||
# Pydantic AI imports
|
||||
try:
|
||||
from pydantic_ai import Agent
|
||||
PYDANTIC_AI_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYDANTIC_AI_AVAILABLE = False
|
||||
Agent = None
|
||||
print("Pydantic AI not available. Install with: pip install pydantic-ai")
|
||||
|
||||
# MCP Protocol imports for direct connection
|
||||
try:
|
||||
from pydantic_ai.mcp import MCPServerStdio
|
||||
from pydantic_ai import exceptions
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
MCP_AVAILABLE = False
|
||||
MCPServerStdio = None
|
||||
exceptions = None
|
||||
print("pydantic_ai.mcp not available. You might need to upgrade pydantic-ai.")
|
||||
|
||||
# Configure logging for this module
|
||||
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_mcp_server_path: str = "uvx"
|
||||
rules_file: str = "rules.yaml"
|
||||
templates_dir: str = "templates"
|
||||
|
||||
def print_tools(tools: List[Any]):
|
||||
"""Pretty print the tools list."""
|
||||
if not tools:
|
||||
print("\nNo tools available.")
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("AVAILABLE TOOLS")
|
||||
print(f"\n{'='*60}")
|
||||
|
||||
for i, tool in enumerate(tools, 1):
|
||||
print(f"\n{i}. {tool.name}")
|
||||
if tool.description:
|
||||
print(f" Description: {tool.description}")
|
||||
|
||||
if hasattr(tool, 'inputSchema') and tool.inputSchema:
|
||||
properties = tool.inputSchema.get("properties", {})
|
||||
if properties:
|
||||
print(" Parameters:")
|
||||
required_params = tool.inputSchema.get("required", [])
|
||||
for prop_name, prop_info in properties.items():
|
||||
prop_type = prop_info.get("type", "unknown")
|
||||
prop_desc = prop_info.get("description", "")
|
||||
required = prop_name in required_params
|
||||
req_str = " (required)" if required else " (optional)"
|
||||
print(f" - {prop_name} ({prop_type}){req_str}: {prop_desc}")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
|
||||
class PydanticAIAnalyzer:
|
||||
"""Pydantic AI powered cycling analyzer"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.mcp_server = None
|
||||
self.available_tools = []
|
||||
self._cached_activity_details = None
|
||||
|
||||
if not PYDANTIC_AI_AVAILABLE or not MCP_AVAILABLE:
|
||||
raise Exception("Pydantic AI or MCP not available. Please check your installation.")
|
||||
|
||||
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"
|
||||
})
|
||||
|
||||
env = os.environ.copy()
|
||||
os.environ["GARTH_TOKEN"] = config.garth_token
|
||||
env["GARTH_TOKEN"] = config.garth_token
|
||||
|
||||
server_executable = shutil.which(config.garth_mcp_server_path)
|
||||
if not server_executable:
|
||||
logger.error(f"'{config.garth_mcp_server_path}' not found in PATH. MCP tools will be unavailable.")
|
||||
else:
|
||||
self.mcp_server = MCPServerStdio(
|
||||
command=server_executable,
|
||||
args=["garth-mcp-server"],
|
||||
env=env,
|
||||
)
|
||||
|
||||
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.""",
|
||||
toolsets=[self.mcp_server] if self.mcp_server else []
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the analyzer and connect to MCP server"""
|
||||
logger.info("Initializing Pydantic AI analyzer...")
|
||||
if self.agent and self.mcp_server:
|
||||
try:
|
||||
logger.info("Attempting to enter agent context...")
|
||||
await asyncio.wait_for(self.agent.__aenter__(), timeout=45)
|
||||
logger.info("✓ Agent context entered successfully")
|
||||
logger.info("Listing available MCP tools...")
|
||||
self.available_tools = await self.mcp_server.list_tools()
|
||||
logger.info(f"✓ Found {len(self.available_tools)} MCP tools.")
|
||||
if self.available_tools:
|
||||
for tool in self.available_tools[:5]: # Log first 5 tools
|
||||
logger.info(f" Tool: {tool.name} - {getattr(tool, 'description', 'No description')}")
|
||||
if len(self.available_tools) > 5:
|
||||
logger.info(f" ... and {len(self.available_tools) - 5} more tools")
|
||||
else:
|
||||
logger.warning("No tools returned from MCP server!")
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Agent initialization timed out. MCP tools will be unavailable.")
|
||||
self.mcp_server = None
|
||||
except Exception as e:
|
||||
logger.error(f"Agent initialization failed: {e}. MCP tools will be unavailable.")
|
||||
logger.error(f"Exception type: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Full initialization traceback: {traceback.format_exc()}")
|
||||
self.mcp_server = None
|
||||
else:
|
||||
logger.warning("MCP server not configured. MCP tools will be unavailable.")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup resources"""
|
||||
if self.agent and self.mcp_server:
|
||||
await self.agent.__aexit__(None, None, None)
|
||||
logger.info("Cleanup completed")
|
||||
|
||||
async def get_recent_cycling_activity_details(self) -> dict:
|
||||
"""Pre-call get_activities and get_activity_details to cache the last cycling activity details"""
|
||||
if self._cached_activity_details is not None:
|
||||
logger.debug("Returning cached activity details")
|
||||
return self._cached_activity_details
|
||||
|
||||
if not self.mcp_server:
|
||||
logger.error("MCP server not available")
|
||||
return {}
|
||||
|
||||
try:
|
||||
logger.debug("Pre-calling get_activities tool")
|
||||
activities_args = {"limit": 10}
|
||||
activities = []
|
||||
try:
|
||||
logger.debug("Bypassing direct_call_tool and using garth.connectapi directly for get_activities")
|
||||
garth.client.loads(self.config.garth_token)
|
||||
from urllib.parse import urlencode
|
||||
params = {"limit": 10}
|
||||
endpoint = "activitylist-service/activities/search/activities"
|
||||
endpoint += "?" + urlencode(params)
|
||||
activities = garth.connectapi(endpoint)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling garth.connectapi directly: {e}", exc_info=True)
|
||||
activities = []
|
||||
|
||||
if not activities:
|
||||
logger.error("Failed to retrieve activities.")
|
||||
return {"error": "Failed to retrieve activities."}
|
||||
|
||||
logger.debug(f"Retrieved {len(activities)} activities")
|
||||
|
||||
# Filter for cycling activities
|
||||
cycling_activities = [
|
||||
act for act in activities
|
||||
if "cycling" in act.get("activityType", {}).get("typeKey", "").lower()
|
||||
]
|
||||
|
||||
if not cycling_activities:
|
||||
logger.warning("No cycling activities found")
|
||||
self._cached_activity_details = {"activities": activities, "last_cycling": None, "details": None}
|
||||
return self._cached_activity_details
|
||||
|
||||
# Get the most recent cycling activity
|
||||
last_cycling = max(cycling_activities, key=lambda x: x.get("start_time", "1970-01-01"))
|
||||
activity_id = last_cycling["activityId"]
|
||||
logger.debug(f"Last cycling activity ID: {activity_id}")
|
||||
|
||||
logger.debug("Pre-calling get_activity_details tool")
|
||||
details = garth.connectapi(f"activity-service/activity/{activity_id}")
|
||||
logger.debug("Retrieved activity details")
|
||||
|
||||
self._cached_activity_details = {
|
||||
"activities": activities,
|
||||
"last_cycling": last_cycling,
|
||||
"details": details
|
||||
}
|
||||
logger.info("Cached recent cycling activity details successfully")
|
||||
return self._cached_activity_details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pre-calling activity tools: {e}", exc_info=True)
|
||||
self._cached_activity_details = {"error": str(e)}
|
||||
return self._cached_activity_details
|
||||
|
||||
async def get_user_profile(self) -> dict:
|
||||
"""Pre-call user_profile tool to cache the response"""
|
||||
if hasattr(self, '_cached_user_profile') and self._cached_user_profile is not None:
|
||||
logger.debug("Returning cached user profile")
|
||||
return self._cached_user_profile
|
||||
|
||||
if not self.mcp_server:
|
||||
logger.error("MCP server not available")
|
||||
return {}
|
||||
|
||||
try:
|
||||
logger.debug("Pre-calling user_profile tool")
|
||||
profile_result = await self.mcp_server.direct_call_tool("user_profile", {})
|
||||
profile = profile_result.output if hasattr(profile_result, 'output') else profile_result
|
||||
logger.debug("Retrieved user profile")
|
||||
|
||||
self._cached_user_profile = profile
|
||||
logger.info("Cached user profile successfully")
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pre-calling user_profile: {e}", exc_info=True)
|
||||
self._cached_user_profile = {"error": str(e)}
|
||||
return self._cached_user_profile
|
||||
|
||||
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...")
|
||||
|
||||
# Get pre-cached data
|
||||
activity_data = await self.get_recent_cycling_activity_details()
|
||||
user_profile = await self.get_user_profile()
|
||||
|
||||
if not activity_data.get("last_cycling"):
|
||||
return "No recent cycling activity found to analyze."
|
||||
|
||||
last_activity = activity_data["last_cycling"]
|
||||
details = activity_data["details"]
|
||||
|
||||
# Summarize key data for prompt
|
||||
activity_summary = f"""
|
||||
Last Cycling Activity:
|
||||
- Start Time: {last_activity.get('start_time', 'N/A')}
|
||||
- Duration: {last_activity.get('duration', 'N/A')} seconds
|
||||
- Distance: {last_activity.get('distance', 'N/A')} meters
|
||||
- Average Speed: {last_activity.get('averageSpeed', 'N/A')} m/s
|
||||
- Average Power: {last_activity.get('avgPower', 'N/A')} W (if available)
|
||||
- Max Power: {last_activity.get('maxPower', 'N/A')} W (if available)
|
||||
- Average Heart Rate: {last_activity.get('avgHr', 'N/A')} bpm (if available)
|
||||
|
||||
Full Activity Details: {json.dumps(details, default=str)}
|
||||
"""
|
||||
|
||||
user_info = f"""
|
||||
User Profile:
|
||||
{json.dumps(user_profile, default=str)}
|
||||
"""
|
||||
|
||||
prompt = f"""
|
||||
Analyze my most recent cycling workout using the provided data. Do not call any tools - all necessary data is already loaded.
|
||||
|
||||
{activity_summary}
|
||||
|
||||
{user_info}
|
||||
|
||||
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 user profile data for baselines)
|
||||
|
||||
Focus on the provided activity details for your analysis.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Create temporary agent without tools for this analysis
|
||||
model_name = f"openrouter:{self.config.openrouter_model}"
|
||||
temp_agent = Agent(
|
||||
model=model_name,
|
||||
system_prompt="""You are an expert cycling coach. Analyze the provided cycling workout data and give actionable insights.
|
||||
Do not use any tools - all data is provided in the prompt.""",
|
||||
toolsets=[]
|
||||
)
|
||||
|
||||
# Enter context for temp agent
|
||||
await asyncio.wait_for(temp_agent.__aenter__(), timeout=30)
|
||||
|
||||
result = await temp_agent.run(prompt)
|
||||
|
||||
# Exit context
|
||||
await temp_agent.__aexit__(None, None, None)
|
||||
|
||||
return str(result)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Temp agent initialization timed out")
|
||||
return "Error: Agent initialization timed out. Please try again."
|
||||
except Exception as e:
|
||||
logger.error(f"Error in workout analysis: {e}")
|
||||
if hasattr(temp_agent, '__aexit__'):
|
||||
await temp_agent.__aexit__(None, None, None)
|
||||
return "Error analyzing workout. Please check the logs for more details."
|
||||
|
||||
async def suggest_next_workout(self, training_rules: str) -> str:
|
||||
"""Suggest next workout using Pydantic AI"""
|
||||
logger.info("Generating workout suggestion with Pydantic AI...")
|
||||
|
||||
# Log available tools before making the call
|
||||
if self.available_tools:
|
||||
tool_names = [tool.name for tool in self.available_tools]
|
||||
logger.info(f"Available MCP tools: {tool_names}")
|
||||
if 'get_activities' not in tool_names:
|
||||
logger.warning("WARNING: 'get_activities' tool not found in available tools!")
|
||||
else:
|
||||
logger.warning("No MCP tools available!")
|
||||
|
||||
prompt = f"""
|
||||
Please suggest my next cycling workout based on my recent training history. Use the get_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_data or nightly_sleep to inform recovery status and workout readiness.
|
||||
"""
|
||||
|
||||
logger.info("About to call agent.run() with workout suggestion prompt")
|
||||
try:
|
||||
result = await self.agent.run(prompt)
|
||||
logger.info("Agent run completed successfully")
|
||||
return result.text
|
||||
except Exception as e:
|
||||
logger.error(f"Error in workout suggestion: {e}")
|
||||
logger.error(f"Exception type: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
|
||||
if "exceeded max retries" in str(e):
|
||||
return "Failed to fetch your activity data from Garmin after several attempts. Please check your connection and try again."
|
||||
return "Error suggesting workout. Please check the logs for more details."
|
||||
|
||||
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...")
|
||||
|
||||
# Get pre-cached data
|
||||
activity_data = await self.get_recent_cycling_activity_details()
|
||||
user_profile = await self.get_user_profile()
|
||||
|
||||
if not activity_data.get("last_cycling"):
|
||||
return f"No recent cycling activity found for {analysis_type} analysis."
|
||||
|
||||
# Summarize recent activities
|
||||
recent_activities = activity_data.get("activities", [])
|
||||
cycling_activities_summary = "\n".join([
|
||||
f"- {act.get('start_time', 'N/A')}: {act.get('activityType', {}).get('typeKey', 'Unknown')} - Duration: {act.get('duration', 'N/A')}s"
|
||||
for act in recent_activities[-5:] # Last 5 activities
|
||||
])
|
||||
|
||||
last_activity = activity_data["last_cycling"]
|
||||
details = activity_data["details"]
|
||||
|
||||
activity_summary = f"""
|
||||
Most Recent Cycling Activity:
|
||||
- Start Time: {last_activity.get('start_time', 'N/A')}
|
||||
- Duration: {last_activity.get('duration', 'N/A')} seconds
|
||||
- Distance: {last_activity.get('distance', 'N/A')} meters
|
||||
- Average Speed: {last_activity.get('averageSpeed', 'N/A')} m/s
|
||||
- Average Power: {last_activity.get('avgPower', 'N/A')} W
|
||||
- Max Power: {last_activity.get('maxPower', 'N/A')} W
|
||||
- Average Heart Rate: {last_activity.get('avgHr', 'N/A')} bpm
|
||||
|
||||
Full Activity Details: {json.dumps(details, default=str)}
|
||||
|
||||
Recent Activities (last 5):
|
||||
{cycling_activities_summary}
|
||||
"""
|
||||
|
||||
user_info = f"""
|
||||
User Profile:
|
||||
{json.dumps(user_profile, default=str)}
|
||||
"""
|
||||
|
||||
prompt = f"""
|
||||
Perform a comprehensive {analysis_type} analysis using the provided cycling training data.
|
||||
Do not call any tools - all core data is already loaded. Base your analysis on the following information:
|
||||
|
||||
{activity_summary}
|
||||
|
||||
{user_info}
|
||||
|
||||
My training rules and goals:
|
||||
{training_rules}
|
||||
|
||||
Focus your {analysis_type} analysis on:
|
||||
1. **Performance Analysis**: Analyze power, heart rate, training load, and recovery metrics from the provided data
|
||||
2. **Training Periodization**: Consider the recent activity patterns and progression
|
||||
3. **Actionable Recommendations**: Provide specific, measurable guidance based on the data
|
||||
4. **Risk Assessment**: Identify any signs of overtraining or injury risk from the available metrics
|
||||
|
||||
Be thorough and use the provided data points to support your recommendations.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Create temporary agent without tools for this analysis
|
||||
model_name = f"openrouter:{self.config.openrouter_model}"
|
||||
temp_agent = Agent(
|
||||
model=model_name,
|
||||
system_prompt="""You are an expert cycling coach. Perform comprehensive analysis using the provided data.
|
||||
Do not use any tools - all relevant data is included in the prompt.""",
|
||||
toolsets=[]
|
||||
)
|
||||
|
||||
# Enter context for temp agent
|
||||
await asyncio.wait_for(temp_agent.__aenter__(), timeout=30)
|
||||
|
||||
result = await temp_agent.run(prompt)
|
||||
|
||||
# Exit context
|
||||
await temp_agent.__aexit__(None, None, None)
|
||||
|
||||
return str(result)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Temp agent initialization timed out")
|
||||
return f"Error: Agent initialization timed out for {analysis_type} analysis."
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enhanced analysis: {e}")
|
||||
if hasattr(temp_agent, '__aexit__'):
|
||||
await temp_agent.__aexit__(None, None, None)
|
||||
return f"Error in {analysis_type} analysis: {e}"
|
||||
Reference in New Issue
Block a user