mcp-server-strava

  • src
import logging import os import time from datetime import datetime from typing import Dict, List, Optional, Union import requests from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP # Настройка логирования logger = logging.getLogger(__name__) class RateLimiter: def __init__(self): self.requests_15min: List[float] = [] self.requests_daily: List[float] = [] self.limit_15min = 100 self.limit_daily = 1000 def can_make_request(self) -> bool: """Проверка возможности сделать запрос""" now = time.time() # Очистка старых запросов self.requests_15min = [t for t in self.requests_15min if now - t < 900] # 15 минут self.requests_daily = [t for t in self.requests_daily if now - t < 86400] # 24 часа return ( len(self.requests_15min) < self.limit_15min and len(self.requests_daily) < self.limit_daily ) def add_request(self): """Регистрация нового запроса""" now = time.time() self.requests_15min.append(now) self.requests_daily.append(now) class StravaAuth: def __init__(self): self.client_id = os.getenv("STRAVA_CLIENT_ID") self.client_secret = os.getenv("STRAVA_CLIENT_SECRET") self.refresh_token = os.getenv("STRAVA_REFRESH_TOKEN") self.access_token = os.getenv("STRAVA_ACCESS_TOKEN") self.token_expires_at = float(os.getenv("STRAVA_TOKEN_EXPIRES_AT", "0")) self._cached_token: Optional[str] = None self._last_refresh: Optional[float] = None self.rate_limiter = RateLimiter() def get_access_token(self) -> str: """Получение актуального токена с проверкой срока действия""" now = datetime.now().timestamp() if not self._cached_token or now >= self.token_expires_at - 300: # 5 минут запас return self.refresh_access_token() return self._cached_token def make_request(self, method: str, url: str, **kwargs) -> requests.Response: """Выполнение запроса с учетом rate limiting""" if not self.rate_limiter.can_make_request(): wait_time = 60 # ждем минуту при достижении лимита logging.warning(f"Rate limit reached, waiting {wait_time} seconds") time.sleep(wait_time) try: response = requests.request(method, url, **kwargs) self.rate_limiter.add_request() response.raise_for_status() return response except requests.exceptions.RequestException as e: logging.error(f"Request error: {e}") raise RuntimeError(str(e)) from e def refresh_access_token(self) -> str: """Обновление токена доступа""" try: logger.debug(f"Отправка запроса на обновление токена. Client ID: {self.client_id}") response = self.make_request( "POST", "https://www.strava.com/oauth/token", data={ "client_id": self.client_id, "client_secret": self.client_secret, "refresh_token": self.refresh_token, "grant_type": "refresh_token", }, ) data = response.json() logger.debug("Получен ответ от Strava API") # Проверяем наличие необходимых полей if "access_token" not in data: raise ValueError("Отсутствует access_token в ответе") # Обновляем токены self.access_token = data["access_token"] self.refresh_token = data.get("refresh_token", self.refresh_token) self.token_expires_at = data["expires_at"] self._cached_token = self.access_token self._last_refresh = datetime.now().timestamp() logger.info("Токены успешно обновлены") return self._cached_token except Exception as e: logger.error(f"Ошибка обновления токена: {e}") logger.debug(f"Client ID: {self.client_id}, Refresh Token: {self.refresh_token[:10]}...") raise # Создаем директорию для логов если её нет log_dir = os.path.join(os.path.dirname(__file__), "..", "logs") os.makedirs(log_dir, exist_ok=True) # Настройка логирования logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler(os.path.join(log_dir, "strava_api.log")), ], ) # Загружаем конфигурацию load_dotenv() # Создаем MCP сервер mcp = FastMCP("Strava Integration") # Создаем экземпляр авторизации strava_auth = StravaAuth() # Проверяем токены при старте try: strava_auth.get_access_token() except Exception as e: logger.error(f"Ошибка при проверке токенов: {e}") @mcp.resource("strava://activities") def get_recent_activities() -> List[Dict]: """Получить последние активности из Strava""" limit = 10 logger.info(f"Запрашиваем последние {limit} активностей") try: access_token = strava_auth.get_access_token() response = strava_auth.make_request( "GET", "https://www.strava.com/api/v3/athlete/activities", headers={"Authorization": f"Bearer {access_token}"}, params={"per_page": limit}, ) activities = response.json() logger.info(f"Получено {len(activities)} активностей") return activities except Exception as e: logger.error(f"Ошибка API Strava: {e}") raise RuntimeError(f"Ошибка получения активностей: {e}") from e @mcp.resource("strava://activities/{activity_id}") def get_activity(activity_id: str) -> dict: """Получить детали конкретной активности Args: activity_id: ID активности Returns: dict: Данные активности """ try: access_token = strava_auth.get_access_token() # Используем strava_auth.make_request вместо прямого вызова requests response = strava_auth.make_request( "GET", f"https://www.strava.com/api/v3/activities/{activity_id}", headers={"Authorization": f"Bearer {access_token}"}, ) activity = response.json() logger.info(f"Получена активность {activity_id}: {activity.get('type')}") return activity except Exception as e: logger.error(f"Ошибка получения активности {activity_id}: {e}") raise RuntimeError("Не удалось получить активность") from e @mcp.resource("strava://athlete/zones") def get_athlete_zones() -> Dict: """Получить тренировочные зоны атлета Returns: Dict: Зоны частоты пульса и мощности """ try: access_token = strava_auth.get_access_token() response = strava_auth.make_request( "GET", "https://www.strava.com/api/v3/athlete/zones", headers={"Authorization": f"Bearer {access_token}"}, ) zones = response.json() logger.info("Получены тренировочные зоны атлета") return { "heart_rate": { "custom_zones": zones.get("heart_rate", {}).get("custom_zones", False), "zones": [ { "min": zone.get("min", 0), "max": zone.get("max", -1), "name": f"Z{i+1} - {_get_zone_name(i)}" } for i, zone in enumerate(zones.get("heart_rate", {}).get("zones", [])) ] }, "power": { "zones": zones.get("power", {}).get("zones", []) } } except Exception as e: logger.error(f"Ошибка получения зон: {e}") raise RuntimeError("Не удалось получить тренировочные зоны") from e def _get_zone_name(index: int) -> str: """Получить название зоны по индексу""" zone_names = { 0: "Recovery", # Восстановление 1: "Endurance", # Выносливость 2: "Tempo", # Темповая 3: "Threshold", # Пороговая 4: "Anaerobic" # Анаэробная } return zone_names.get(index, "Unknown") @mcp.resource("strava:///athlete/clubs") def get_athlete_stats() -> Dict: """Получить клубы атлета Returns: Dict: Клубы атлета """ try: access_token = strava_auth.get_access_token() response = strava_auth.make_request( "GET", "https://www.strava.com/api/v3/athlete/clubs", headers={"Authorization": f"Bearer {access_token}"}, ) clubs = response.json() logger.info(f"Получены клубы атлета: {len(clubs)}") return clubs except Exception as e: logger.error(f"Ошибка получения клубов: {e}") raise RuntimeError("Не удалось получить клубы атлета") from e @mcp.resource("strava://gear/{gear_id}") def get_gear(gear_id: str) -> Dict: """Получить информацию о снаряжении Args: gear_id: ID снаряжения Returns: Dict: Информация о снаряжении """ try: access_token = strava_auth.get_access_token() response = strava_auth.make_request( "GET", f"https://www.strava.com/api/v3/gear/{gear_id}", headers={"Authorization": f"Bearer {access_token}"}, ) gear = response.json() logger.info(f"Получено снаряжение {gear_id}: {gear.get('name')}") return gear except Exception as e: logger.error(f"Ошибка получения снаряжения {gear_id}: {e}") raise RuntimeError("Не удалось получить снаряжение") from e @mcp.tool() def analyze_activity(activity_id: Union[str, int]) -> dict: """Анализ активности из Strava Args: activity_id: ID активности (строка или число) Returns: dict: Результаты анализа активности """ # Преобразуем activity_id в строку activity_id = str(activity_id) try: activity = get_activity(activity_id) return { "type": activity.get("type"), "distance": activity.get("distance"), "moving_time": activity.get("moving_time"), "analysis": {"pace": _calculate_pace(activity), "effort": _calculate_effort(activity)}, } except Exception as e: logger.error(f"Ошибка анализа активности {activity_id}: {e}") return {"error": f"Не удалось проанализировать активность: {str(e)}"} def _calculate_pace(activity: dict) -> float: """Расчет темпа активности""" try: if activity.get("type") == "Run": # Для бега: мин/км return (activity.get("moving_time", 0) / 60) / (activity.get("distance", 0) / 1000) elif activity.get("type") == "Ride": # Для велосипеда: км/ч return (activity.get("distance", 0) / 1000) / (activity.get("moving_time", 0) / 3600) return 0 except (TypeError, ZeroDivisionError): return 0 def _calculate_effort(activity: dict) -> str: """Оценка нагрузки""" if "average_heartrate" not in activity: return "Неизвестно" hr = activity["average_heartrate"] if hr < 120: return "Легкая" if hr < 150: return "Средняя" return "Высокая" @mcp.tool() def analyze_training_load(activities: List[Dict]) -> Dict: """Анализ тренировочной нагрузки""" summary = { "activities_count": len(activities), "total_distance": 0, "total_time": 0, "activities_by_type": {}, "heart_rate_zones": { "easy": 0, # ЧСС < 120 "medium": 0, # ЧСС 120-150 "hard": 0, # ЧСС > 150 }, } for activity in activities: activity_type = activity.get("type") # Обновляем счетчик типа активности if activity_type not in summary["activities_by_type"]: summary["activities_by_type"][activity_type] = 0 summary["activities_by_type"][activity_type] += 1 # Суммируем дистанцию и время summary["total_distance"] += activity.get("distance", 0) summary["total_time"] += activity.get("moving_time", 0) # Анализируем зоны ЧСС hr = activity.get("average_heartrate", 0) if hr: if hr < 120: summary["heart_rate_zones"]["easy"] += 1 elif hr < 150: summary["heart_rate_zones"]["medium"] += 1 else: summary["heart_rate_zones"]["hard"] += 1 # Конвертируем единицы измерения summary["total_distance"] = round(summary["total_distance"] / 1000, 2) # в километры summary["total_time"] = round(summary["total_time"] / 3600, 2) # в часы return summary @mcp.tool() def get_activity_recommendations() -> Dict: """Получить рекомендации по тренировкам на основе анализа последних активностей""" activities = get_recent_activities() analysis = analyze_training_load(activities) recommendations = [] # Анализ разнообразия тренировок activity_types = analysis["activities_by_type"] total_activities = analysis["activities_count"] # Анализ интенсивности по зонам zones = analysis["heart_rate_zones"] total_zone_activities = sum(zones.values()) if total_zone_activities > 0: easy_percent = (zones["easy"] / total_zone_activities) * 100 medium_percent = (zones["medium"] / total_zone_activities) * 100 hard_percent = (zones["hard"] / total_zone_activities) * 100 # Проверка распределения интенсивности if easy_percent < 70: recommendations.append( f"Слишком мало легких тренировок ({easy_percent:.0f}%). " "Рекомендуется:\n" "- Добавить восстановительные тренировки\n" "- Больше базовых тренировок в низких пульсовых зонах\n" "- Использовать контроль пульса во время тренировок" ) if medium_percent > 40: recommendations.append( f"Большой процент тренировок в средней зоне ({medium_percent:.0f}%). " "Рекомендуется:\n" "- Четко разделять легкие и интенсивные тренировки\n" "- Избегать тренировок в 'серой зоне'" ) # Анализ объемов по видам спорта if "Run" in activity_types: run_volume = sum(a.get("distance", 0) for a in activities if a.get("type") == "Run") / 1000 if run_volume < 20: recommendations.append( f"Беговой объем ({run_volume:.1f} км) ниже оптимального.\n" "Рекомендации по увеличению:\n" "- Добавить 1-2 км к длинной пробежке еженедельно\n" "- Включить легкие восстановительные пробежки\n" "- Постепенно довести объем до 30-40 км в неделю" ) # Анализ общего объема weekly_distance = analysis["total_distance"] weekly_hours = analysis["total_time"] if weekly_hours < 5: recommendations.append( f"Общий объем ({weekly_hours:.1f} ч) можно увеличить.\n" "Рекомендации:\n" "- Постепенно добавлять по 30 минут в неделю\n" "- Включить кросс-тренировки для разнообразия\n" "- Следить за самочувствием при увеличении нагрузок" ) # Рекомендации по восстановлению if total_zone_activities > 5: recommendations.append( "Рекомендации по восстановлению:\n" "- Обеспечить 7-8 часов сна\n" "- Планировать легкие дни после интенсивных тренировок\n" "- Следить за питанием и гидратацией" ) # Если всё сбалансировано if not recommendations: recommendations.append( "Тренировки хорошо сбалансированы!\n" "Рекомендации по поддержанию формы:\n" "- Продолжать текущий план тренировок\n" "- Вести дневник тренировок\n" "- Регулярно анализировать прогресс" ) # Форматируем вывод для лучшей читаемости result = { "analysis": { "activities": { "total": analysis["activities_count"], "distance": f"{analysis['total_distance']:.1f} км", "time": f"{analysis['total_time']:.1f} ч", "distribution": { activity: { "count": count, "percent": f"{(count / total_activities * 100):.0f}%", } for activity, count in activity_types.items() }, }, "intensity": { "zones": { "easy": f"{easy_percent:.0f}%" if total_zone_activities > 0 else "0%", "medium": f"{medium_percent:.0f}%" if total_zone_activities > 0 else "0%", "hard": f"{hard_percent:.0f}%" if total_zone_activities > 0 else "0%", }, "status": "Сбалансировано" if 60 <= easy_percent <= 80 else "Требует корректировки", }, }, "recommendations": [ {"category": recommendation.split("\n")[0], "details": recommendation.split("\n")[1:]} for recommendation in recommendations ], "summary": { "status": "✅ Тренировки сбалансированы" if not recommendations else "⚠️ Есть рекомендации", "weekly": { "activities": total_activities, "distance": f"{weekly_distance:.1f} км", "time": f"{weekly_hours:.1f} ч", }, }, } return result