FirstCycling MCP Server

MIT License
  • Apple
from typing import Any import sys import os from mcp.server.fastmcp import FastMCP import requests from bs4 import BeautifulSoup from typing import Dict, List, Optional, Union from datetime import datetime from FirstCyclingAPI.first_cycling_api.rider.rider import Rider from FirstCyclingAPI.first_cycling_api.race.race import Race from FirstCyclingAPI.first_cycling_api.api import FirstCyclingAPI import re # Add the FirstCyclingAPI directory to the Python path sys.path.append(os.path.join(os.path.dirname(__file__), "FirstCyclingAPI")) # Import from the FirstCycling API from first_cycling_api.rider.rider import Rider from first_cycling_api.race.race import RaceEdition # Initialize FastMCP server mcp = FastMCP("firstcycling") @mcp.tool( name="get_rider_year_results", description="""Retrieve detailed results for a professional cyclist for a specific year. This tool provides comprehensive information about a rider's performance in all races during a given calendar year. It includes positions achieved, race categories, dates, and additional details. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get 2023 results for Tadej Pogačar (ID: 16973) - Get 2022 results for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Complete results for the specified year - Position and time for each race - Race category and details - Chronological organization by date""" ) async def get_rider_year_results(rider_id: int, year: int) -> str: """Get detailed results for a professional cyclist for a specific year. Args: rider_id: The FirstCycling rider ID (e.g., 16973 for Tadej Pogačar) year: The year to get results for (e.g., 2023) """ try: # Create a rider instance rider = Rider(rider_id) # Get year results year_results = rider.year_results(year) # Build information string info = "" # Get rider name rider_name = None if hasattr(year_results, 'header_details') and year_results.header_details: if 'name' in year_results.header_details: rider_name = year_results.header_details['name'] else: # Try to find name in soup if hasattr(year_results, 'soup') and year_results.soup: name_element = year_results.soup.find('h1') if name_element: rider_name = name_element.text.strip() # Format title if rider_name: info += f"{year} Results for {rider_name}:\n\n" else: info += f"{year} Results for Rider ID {rider_id}:\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(year_results, 'results_df') and not (year_results.results_df is None or year_results.results_df.empty): # Use standard parsing results_df = year_results.results_df # Sort by date if 'Date' in results_df.columns: results_df = results_df.sort_values('Date') for _, row in results_df.iterrows(): date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') pos = row.get('Pos', 'N/A') category = row.get('CAT', 'N/A') result_line = f"{date} - {race}" if category and category != 'N/A': result_line += f" ({category})" result_line += f": {pos}" info += result_line + "\n" else: # Direct HTML parsing if not hasattr(year_results, 'soup') or not year_results.soup: return f"No results found for rider ID {rider_id} in year {year}. This rider ID may not exist or the rider didn't compete this year." soup = year_results.soup # Find results table results_table = None tables = soup.find_all('table') # Look for the appropriate table that contains race results for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and any(keyword in ' '.join(headers).lower() for keyword in ['date', 'race', 'result', 'position']): results_table = table break if not results_table: return f"No results table found for rider ID {rider_id} in year {year}. The rider may not have competed this year." # Parse results data rows = results_table.find_all('tr') if len(rows) <= 1: # Only header row, no data return f"No race results found for rider ID {rider_id} in year {year}." # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data (positions may vary depending on table structure) date = cols[0].text.strip() if len(cols) > 0 else "N/A" race = cols[1].text.strip() if len(cols) > 1 else "N/A" pos = cols[2].text.strip() if len(cols) > 2 else "N/A" # Try to find category if available category = "N/A" for i in range(3, min(6, len(cols))): # Check a few columns for possible category col_text = cols[i].text.strip() if col_text and len(col_text) <= 5 and any(c in col_text for c in [".", "WT", "1", "2"]): category = col_text break result_line = f"{date} - {race}" if category and category != 'N/A': result_line += f" ({category})" result_line += f": {pos}" info += result_line + "\n" if not info.endswith("\n\n"): info += "\n" return info except Exception as e: return f"Error retrieving {year} results for rider ID {rider_id}: {str(e)}" @mcp.tool( name="get_rider_victories", description="""Get a comprehensive list of a rider's UCI victories. This tool retrieves detailed information about all UCI-registered race victories achieved by the cyclist throughout their career. Victories can be filtered to show only WorldTour wins if desired. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get all UCI victories for Tadej Pogačar (ID: 16973) - Get WorldTour victories for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Complete list of victories - Race details including category - Date and year of each victory - Option to filter by WorldTour races only""" ) async def get_rider_victories(rider_id: int, world_tour_only: bool = False) -> str: """Get a comprehensive list of a rider's UCI victories. Args: rider_id: The FirstCycling rider ID (e.g., 16973 for Tadej Pogačar) world_tour_only: If True, only shows WorldTour victories """ try: # Create a rider instance rider = Rider(rider_id) # Get victories (UCI victories by default) victories = rider.victories(world_tour=world_tour_only, uci=True) # Build information string info = "" # Get rider name rider_name = None if hasattr(victories, 'header_details') and victories.header_details: if 'name' in victories.header_details: rider_name = victories.header_details['name'] else: # Try to find name in soup if hasattr(victories, 'soup') and victories.soup: name_element = victories.soup.find('h1') if name_element: rider_name = name_element.text.strip() # Format title based on filter if rider_name: if world_tour_only: info += f"WorldTour Victories for {rider_name}:\n\n" else: info += f"UCI Victories for {rider_name}:\n\n" else: if world_tour_only: info += f"WorldTour Victories for Rider ID {rider_id}:\n\n" else: info += f"UCI Victories for Rider ID {rider_id}:\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(victories, 'results_df') and not (victories.results_df is None or victories.results_df.empty): # Use standard parsing results_df = victories.results_df # Group by year results_df = results_df.sort_values('Year', ascending=False) for year in results_df['Year'].unique(): year_data = results_df[results_df['Year'] == year] info += f"{year}:\n" for _, row in year_data.iterrows(): date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') category = row.get('CAT', 'N/A') result_line = f" {date} - {race}" if category and category != 'N/A': result_line += f" ({category})" info += result_line + "\n" info += "\n" else: # Direct HTML parsing if not hasattr(victories, 'soup') or not victories.soup: return f"No victories data found for rider ID {rider_id}. This rider ID may not exist or has no recorded victories." soup = victories.soup # Find victories table victories_table = None tables = soup.find_all('table') # Look for the appropriate table that contains victories for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if table.find_all('th') else [] if len(headers) >= 3 and any(keyword in ' '.join(headers).lower() for keyword in ['date', 'race', 'year']): victories_table = table break if not victories_table: return f"No victories table found for rider ID {rider_id}. The rider may not have any recorded victories." # Parse victories data rows = victories_table.find_all('tr') if len(rows) <= 1: # Only header row, no data return f"No victories found for rider ID {rider_id}." # Get headers to determine column positions headers = [th.text.strip() for th in rows[0].find_all('th')] if rows[0].find_all('th') else [] # Find column indices year_idx = next((i for i, h in enumerate(headers) if "Year" in h), None) date_idx = next((i for i, h in enumerate(headers) if "Date" in h), None) race_idx = next((i for i, h in enumerate(headers) if "Race" in h), 1) # Default to second column cat_idx = next((i for i, h in enumerate(headers) if "CAT" in h), None) # Extract and organize victories by year victories_by_year = {} # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data year = cols[year_idx].text.strip() if year_idx is not None and year_idx < len(cols) else "Unknown" date = cols[date_idx].text.strip() if date_idx is not None and date_idx < len(cols) else "N/A" race = cols[race_idx].text.strip() if race_idx is not None and race_idx < len(cols) else cols[1].text.strip() category = cols[cat_idx].text.strip() if cat_idx is not None and cat_idx < len(cols) else "N/A" # If year not found in date column, try to extract from date if year == "Unknown" and date != "N/A": year_match = re.search(r'(\d{4})', date) if year_match: year = year_match.group(1) if year not in victories_by_year: victories_by_year[year] = [] victories_by_year[year].append({ 'date': date, 'race': race, 'category': category }) # Sort years in descending order and format output for year in sorted(victories_by_year.keys(), reverse=True): info += f"{year}:\n" for victory in victories_by_year[year]: result_line = f" {victory['date']} - {victory['race']}" if victory['category'] and victory['category'] != 'N/A': result_line += f" ({victory['category']})" info += result_line + "\n" info += "\n" if not victories_by_year: info += "No victories found.\n" return info except Exception as e: return f"Error retrieving victories for rider ID {rider_id}: {str(e)}" @mcp.tool( name="get_rider_teams", description="""Get a detailed history of a professional cyclist's team affiliations throughout their career. This tool provides a chronological list of all teams the rider has been part of, including years and team details. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get team history for Peter Sagan (ID: 12345) - Get career team changes for Chris Froome (ID: 67890) Returns a formatted string with: - Complete team history - Years with each team - Team names and details - Chronological organization""" ) async def get_rider_teams(rider_id: int) -> str: """Get a detailed history of a professional cyclist's team affiliations throughout their career. Args: rider_id: The FirstCycling rider ID (e.g., 12345 for Peter Sagan) """ try: # Create a rider instance rider = Rider(rider_id) # Get teams history teams_history = rider.teams() # Build information string info = "" # Get rider name rider_name = None if hasattr(teams_history, 'header_details') and teams_history.header_details: if 'name' in teams_history.header_details: rider_name = teams_history.header_details['name'] else: # Try to find name in soup if hasattr(teams_history, 'soup') and teams_history.soup: name_element = teams_history.soup.find('h1') if name_element: rider_name = name_element.text.strip() # Format title if rider_name: info += f"Team History for {rider_name}:\n\n" else: info += f"Team History for Rider ID {rider_id}:\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(teams_history, 'results_df') and not (teams_history.results_df is None or teams_history.results_df.empty): # Use standard parsing results_df = teams_history.results_df # Sort by year (most recent first) results_df = results_df.sort_values('Year', ascending=False) for _, row in results_df.iterrows(): year = row.get('Year', 'N/A') team = row.get('Team', 'N/A') info += f"{year}: {team}\n" else: # Direct HTML parsing if not hasattr(teams_history, 'soup') or not teams_history.soup: return f"No team history found for rider ID {rider_id}. This rider ID may not exist." soup = teams_history.soup # Find teams table teams_table = None tables = soup.find_all('table') # Look for the appropriate table that contains team history for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if table.find_all('th') else [] if len(headers) >= 2 and any(keyword in ' '.join(headers).lower() for keyword in ['year', 'team', 'season']): teams_table = table break if not teams_table: # Try to find any table that might contain years and teams for table in tables: rows = table.find_all('tr') if len(rows) >= 2: # At least a header and one data row # Check first data row for year-like and team-like content cols = rows[1].find_all('td') if len(cols) >= 2: # Check if first column contains a year if re.match(r'\d{4}', cols[0].text.strip()): teams_table = table break if not teams_table: return f"No team history table found for rider ID {rider_id}." # Parse teams data rows = teams_table.find_all('tr') if len(rows) <= 1: # Only header row, no data return f"No team history found for rider ID {rider_id}." # Get headers to determine column positions headers = [th.text.strip() for th in rows[0].find_all('th')] if rows[0].find_all('th') else [] # Find column indices year_idx = next((i for i, h in enumerate(headers) if "Year" in h), 0) # Default to first column team_idx = next((i for i, h in enumerate(headers) if "Team" in h), 1) # Default to second column # Extract teams by year teams_by_year = [] # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 2: # Ensure it's a data row continue # Extract data year = cols[year_idx].text.strip() if year_idx < len(cols) else "Unknown" team = cols[team_idx].text.strip() if team_idx < len(cols) else cols[1].text.strip() # Sanitize data if year and team: teams_by_year.append({ 'year': year, 'team': team }) # Sort years in descending order and format output teams_by_year.sort(key=lambda x: x['year'], reverse=True) for team_entry in teams_by_year: info += f"{team_entry['year']}: {team_entry['team']}\n" if not teams_by_year: info += "No team history found.\n" return info except Exception as e: return f"Error retrieving team history for rider ID {rider_id}: {str(e)}" @mcp.tool( name="search_rider", description="""Search for professional cyclists by name. This tool helps find riders by their name, returning a list of matching riders with their IDs and basic information. This is useful when you need a rider's ID for other operations but only know their name. Example usage: - Search for "Tadej Pogacar" to find Tadej Pogačar's ID - Search for "Van Aert" to find Wout van Aert's ID Returns a formatted string with: - List of matching riders - Each rider's ID, name, nationality, and current team - Number of matches found""" ) async def search_rider(query: str) -> str: """Search for riders by name. Args: query (str): The search query string to find riders by name. Returns: str: A formatted string containing matching riders with their details: - Rider ID - Rider name - Nationality - Current team """ try: # Search for riders using the Rider.search method riders = Rider.search(query) if not riders: return f"No riders found matching the query '{query}'." # Build results string info = f"Found {len(riders)} riders matching '{query}':\n\n" for rider in riders: info += f"ID: {rider['id']}\n" info += f"Name: {rider['name']}\n" if rider.get('nationality'): info += f"Nationality: {rider['nationality'].upper()}\n" if rider.get('team'): info += f"Team: {rider['team']}\n" info += "\n" return info except Exception as e: return f"Error searching for riders: {str(e)}" @mcp.tool( name="get_rider_info", description="""Get comprehensive information about a professional cyclist including their current team, nationality, date of birth, and recent race results. This tool provides a detailed overview of a rider's current status and recent performance in professional cycling races. The information includes their current team affiliation, nationality, age, and their most recent race results with positions and times. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get basic info for Tadej Pogačar (ID: 16973) - Get basic info for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Full name and current team - Nationality and date of birth - UCI ID and social media handles - Last 5 race results with positions and times - Total number of UCI victories""" ) async def get_rider_info(rider_id: int) -> str: """Get basic information about a rider. Args: rider_id (int): The unique identifier for the rider. This ID can be found on FirstCycling.com in the rider's profile URL (e.g., for rider 12345, the URL would be firstcycling.com/rider/12345). Returns: str: A formatted string containing the rider's information including: - Full name - Current team (if available) - Nationality - Date of birth - Recent race results (last 5 races) with positions and times Raises: Exception: If the rider is not found or if there are connection issues. """ try: # Create a rider instance rider = Rider(rider_id) # Use direct HTML parsing approach to handle cases where the regular parsing fails try: # Try to get rider year results using standard method year_results = rider.year_results() # Check if results exist if year_results is None or not hasattr(year_results, 'results_df') or year_results.results_df.empty: raise Exception("No results found using standard method") # Extract details from the response header_details = year_results.header_details sidebar_details = year_results.sidebar_details # Build rider information string info = "" # Add name from header details if header_details and 'name' in header_details: info += f"Name: {header_details['name']}\n" else: info += f"Rider ID: {rider_id}\n" # Add team if available if header_details and 'current_team' in header_details: info += f"Team: {header_details['current_team']}\n" # Add Twitter/social media if available if header_details and 'twitter_handle' in header_details: info += f"Twitter: @{header_details['twitter_handle']}\n" # Add information from sidebar details if sidebar_details: if 'Nationality' in sidebar_details: info += f"Nationality: {sidebar_details['Nationality']}\n" if 'Date of Birth' in sidebar_details: info += f"Date of Birth: {sidebar_details['Date of Birth']}\n" if 'UCI ID' in sidebar_details: info += f"UCI ID: {sidebar_details['UCI ID']}\n" # Get results for current year if hasattr(year_results, 'results_df') and not year_results.results_df.empty: info += "\nRecent Results:\n" results_count = min(5, len(year_results.results_df)) for i in range(results_count): row = year_results.results_df.iloc[i] date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') pos = row.get('Pos', 'N/A') info += f"{i+1}. {date} - {race}: {pos}\n" # Add victories if available (just a count) try: victories = rider.victories(uci=True) if hasattr(victories, 'results_df') and not victories.results_df.empty: info += f"\nUCI Victories: {len(victories.results_df)}\n" except: pass return info except Exception as parsing_error: # If standard parsing method fails, use direct HTML parsing # Get raw HTML for the rider page url = f"https://firstcycling.com/rider.php?r={rider_id}" response = requests.get(url) if response.status_code != 200: return f"Failed to retrieve data for rider ID {rider_id}. Status code: {response.status_code}" # Parse the HTML soup = BeautifulSoup(response.text, 'html.parser') # Check if the page indicates the rider doesn't exist if "not found" in soup.text.lower() or "no results found" in soup.text.lower(): return f"Rider ID {rider_id} does not exist on FirstCycling.com." # Build rider information string info = "" # Get rider name from the heading name_element = soup.find('h1') if name_element: rider_name = name_element.text.strip() info += f"Name: {rider_name}\n" else: info += f"Rider ID: {rider_id}\n" # Get current team - typically in a div after the rider name team_element = soup.find('span', class_='blue') if team_element: team_name = team_element.text.strip() info += f"Team: {team_name}\n" # Try to find the sidebar details (nationality, birth date, etc.) sidebar = soup.find('div', class_='rp-info') if sidebar: detail_rows = sidebar.find_all('tr') for row in detail_rows: cells = row.find_all('td') if len(cells) >= 2: key = cells[0].text.strip().rstrip(':') value = cells[1].text.strip() if key and value: info += f"{key}: {value}\n" # Try to find recent results tables = soup.find_all('table') results_table = None # Look for a table that has race results for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and ('Date' in headers or 'Race' in headers): results_table = table break if results_table: # Get the headers to identify column positions headers = [th.text.strip() for th in results_table.find_all('th')] date_idx = headers.index('Date') if 'Date' in headers else None race_idx = headers.index('Race') if 'Race' in headers else None pos_idx = headers.index('Pos') if 'Pos' in headers else None if date_idx is not None and race_idx is not None and pos_idx is not None: # Extract up to 5 recent results rows = results_table.find_all('tr')[1:6] # Skip header row, take up to 5 rows if rows: info += "\nRecent Results:\n" for i, row in enumerate(rows): cells = row.find_all('td') if len(cells) > max(date_idx, race_idx, pos_idx): date = cells[date_idx].text.strip() race = cells[race_idx].text.strip() pos = cells[pos_idx].text.strip() info += f"{i+1}. {date} - {race}: {pos}\n" # Try to find victories count # This can be tricky with direct parsing, often in a different section victories_section = soup.find(text=lambda text: text and 'victories' in text.lower()) if victories_section: # Try to extract the number from text like "X UCI victories" victory_text = victories_section.strip() victory_match = re.search(r'(\d+)\s+UCI\s+victories', victory_text, re.IGNORECASE) if victory_match: victories_count = victory_match.group(1) info += f"\nUCI Victories: {victories_count}\n" return info except Exception as e: return f"Error retrieving rider information for ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_rider_best_results", description="""Retrieve the best career results of a professional cyclist, including their top finishes in various races. This tool provides a comprehensive overview of a rider's most significant achievements throughout their career, including their highest positions in major races, stage wins, and overall classifications. Results are sorted by importance and include detailed information about each race. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get top 10 best results for Tadej Pogačar (ID: 16973) - Get top 5 best results for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Rider's name and career highlights - Top results sorted by importance - Race details including category and country - Date and position for each result""" ) async def get_rider_best_results(rider_id: int, limit: int = 10) -> str: """Get the best results of a rider throughout their career. Args: rider_id (int): The unique identifier for the rider. This ID can be found on FirstCycling.com in the rider's profile URL (e.g., for rider 12345, the URL would be firstcycling.com/rider/12345). limit (int, optional): The maximum number of results to return. Defaults to 10. This parameter helps control the amount of data returned and can be adjusted based on the level of detail needed. Maximum recommended value is 20. Returns: str: A formatted string containing the rider's best results, including: - Race name and edition - Date of the race - Position achieved - Time or gap to winner (if applicable) - Race category and type - Any special achievements (e.g., stage wins, points classification) Raises: Exception: If the rider is not found or if there are connection issues. """ try: # Create a rider instance rider = Rider(rider_id) # Get best results best_results = rider.best_results() # Check if results exist if best_results is None or not hasattr(best_results, 'results_df') or best_results.results_df.empty: return f"No best results found for rider ID {rider_id}. Check if this rider has results on FirstCycling.com." # Build results information string info = "" # Add rider name if available from header details if hasattr(best_results, 'header_details') and best_results.header_details and best_results.header_details.get('current_team'): rider_name = best_results.soup.find('h1').text.strip() if best_results.soup.find('h1') else f"Rider ID {rider_id}" info += f"Best Results for {rider_name}:\n\n" else: info += f"Best Results for Rider ID {rider_id}:\n\n" # Get top results results_df = best_results.results_df.head(limit) for _, row in results_df.iterrows(): pos = row.get('Pos', 'N/A') race = row.get('Race', 'N/A') editions = row.get('Editions', 'N/A') category = row.get('CAT', '') country = row.get('Race_Country', '') result_line = f"{pos}. {race}" if category: result_line += f" ({category})" if editions != 'N/A': result_line += f" - {editions}" if country: result_line += f" - {country}" info += result_line + "\n" return info except Exception as e: return f"Error retrieving best results for rider ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_rider_grand_tour_results", description="""Get comprehensive results for a rider in Grand Tours (Tour de France, Giro d'Italia, and Vuelta a España). This tool provides detailed information about a rider's performance in cycling's most prestigious three-week races, including their overall classification positions, stage wins, and special classification results. The data is organized chronologically and includes all relevant race details. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get Grand Tour results for Tadej Pogačar (ID: 16973) - Get Grand Tour results for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Results for each Grand Tour (Tour de France, Giro, Vuelta) - Overall classification positions - Stage wins and special classification results - Time gaps and race details""" ) async def get_rider_grand_tour_results(rider_id: int) -> str: """Get results for a rider in Grand Tours. Args: rider_id (int): The unique identifier for the rider. This ID can be found on FirstCycling.com in the rider's profile URL (e.g., for rider 12345, the URL would be firstcycling.com/rider/12345). Returns: str: A formatted string containing the rider's Grand Tour results, including: - Race name and year - Overall classification position - Time or gap to winner - Stage wins (if any) - Points classification results - Mountains classification results - Young rider classification results (if applicable) - Team classification results Raises: Exception: If the rider is not found or if there are connection issues. """ try: # Create a rider instance rider = Rider(rider_id) # Get grand tour results grand_tour_results = rider.grand_tour_results() # Build information string info = "" # Get rider name rider_name = None if hasattr(grand_tour_results, 'header_details') and grand_tour_results.header_details and 'name' in grand_tour_results.header_details: rider_name = grand_tour_results.header_details['name'] else: # Try to extract rider name from page title if hasattr(grand_tour_results, 'soup') and grand_tour_results.soup: title = grand_tour_results.soup.find('title') if title and '|' in title.text: rider_name = title.text.split('|')[0].strip() # Format title if rider_name: info += f"Grand Tour Results for {rider_name}:\n\n" else: info += f"Grand Tour Results for Rider ID {rider_id}:\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(grand_tour_results, 'results_df') and not (grand_tour_results.results_df is None or grand_tour_results.results_df.empty): # Use standard parsing results_df = grand_tour_results.results_df # Group results by race for race in results_df['Race'].unique(): race_results = results_df[results_df['Race'] == race] info += f"{race}:\n" # Sort by year (most recent first) race_results = race_results.sort_values('Year', ascending=False) for _, row in race_results.iterrows(): year = row.get('Year', 'N/A') pos = row.get('Pos', 'N/A') time = row.get('Time', '') result_line = f" {year}: {pos}" if time: result_line += f" - {time}" info += result_line + "\n" info += "\n" else: # Direct HTML parsing if not hasattr(grand_tour_results, 'soup') or not grand_tour_results.soup: return f"No Grand Tour results found for rider ID {rider_id}. This rider ID may not exist." soup = grand_tour_results.soup # Find Grand Tour results table tables = soup.find_all('table') gt_table = None # Look for the appropriate table that contains Grand Tour results # Usually it's a table with "Tour de France", "Giro d'Italia", or "Vuelta a España" mentioned grand_tours = ["Tour de France", "Giro d'Italia", "Vuelta a España"] for table in tables: for gt in grand_tours: if gt in table.text: gt_table = table break if gt_table: break # If not found by name, look for a table with "Race" and "Year" columns headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and "Race" in headers and "Year" in headers: gt_table = table break if not gt_table: return f"Could not find Grand Tour results table for rider ID {rider_id}." # Parse Grand Tour data rows = gt_table.find_all('tr') gt_data = [] # Get column indices from header row headers = [th.text.strip() for th in rows[0].find_all('th')] race_idx = next((i for i, h in enumerate(headers) if "Race" in h), None) year_idx = next((i for i, h in enumerate(headers) if "Year" in h), None) pos_idx = next((i for i, h in enumerate(headers) if "Pos" in h), None) time_idx = next((i for i, h in enumerate(headers) if "Time" in h), None) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data race_text = cols[race_idx].text.strip() if race_idx is not None and race_idx < len(cols) else "N/A" year_text = cols[year_idx].text.strip() if year_idx is not None and year_idx < len(cols) else "N/A" pos_text = cols[pos_idx].text.strip() if pos_idx is not None and pos_idx < len(cols) else "N/A" time_text = cols[time_idx].text.strip() if time_idx is not None and time_idx < len(cols) else "" # Only include if it's a Grand Tour if any(gt in race_text for gt in grand_tours): gt_data.append({ 'Race': race_text, 'Year': year_text, 'Pos': pos_text, 'Time': time_text }) # Group by race race_grouped = {} for result in gt_data: race = result['Race'] if race not in race_grouped: race_grouped[race] = [] race_grouped[race].append(result) # Format output by race for race, results in race_grouped.items(): info += f"{race}:\n" # Sort by year (most recent first) results.sort(key=lambda x: x['Year'], reverse=True) for result in results: result_line = f" {result['Year']}: {result['Pos']}" if result['Time']: result_line += f" - {result['Time']}" info += result_line + "\n" info += "\n" if not gt_data: info += "No Grand Tour results found for this rider.\n" return info except Exception as e: return f"Error retrieving Grand Tour results for rider ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_rider_monument_results", description="""Retrieve detailed results for a rider in cycling's five Monument races (Milan-San Remo, Tour of Flanders, Paris-Roubaix, Liège-Bastogne-Liège, and Il Lombardia). These are the most prestigious one-day races in professional cycling. The tool provides comprehensive information about a rider's performance in these historic races, including their positions, times, and any special achievements. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get Monument results for Tadej Pogačar (ID: 16973) - Get Monument results for Mathieu van der Poel (ID: 16975) Returns a formatted string with: - Results for each Monument race - Position and time for each participation - Race details and special achievements - Chronological organization by year""" ) async def get_rider_monument_results(rider_id: int) -> str: """Get results for a rider in the five Monument races. Args: rider_id (int): The unique identifier for the rider. This ID can be found on FirstCycling.com in the rider's profile URL (e.g., for rider 12345, the URL would be firstcycling.com/rider/12345). Returns: str: A formatted string containing the rider's Monument race results, including: - Race name and year - Position achieved - Time or gap to winner - Race distance - Any special achievements or notable moments - Team performance Raises: Exception: If the rider is not found or if there are connection issues. """ try: # Create a rider instance rider = Rider(rider_id) # Get monument results monument_results = rider.monument_results() # Check if results exist if monument_results is None or not hasattr(monument_results, 'results_df') or monument_results.results_df.empty: return f"No Monument results found for rider ID {rider_id}. This rider ID may not exist." # Build results information string info = "" # Add rider name if available from header details if hasattr(monument_results, 'header_details') and monument_results.header_details and 'name' in monument_results.header_details: info += f"Monument Results for {monument_results.header_details['name']}:\n\n" else: info += f"Monument Results for Rider ID {rider_id}:\n\n" # Get results for each Monument monument_races = { "Milano-Sanremo": [], "Paris-Roubaix": [], "Ronde van Vlaanderen": [], "Liège-Bastogne-Liège": [], "Il Lombardia": [] } # Group results by monument races for _, row in monument_results.results_df.iterrows(): race_name = row.get('Race', '') year = row.get('Year', '') position = row.get('Pos', '') # Check if this is one of the 5 monuments for monument in monument_races: if monument in race_name: monument_races[monument].append((year, position)) break # Format and add results for each monument for monument, results in monument_races.items(): if not results: continue info += f"{monument}:\n" # Sort results by year in descending order results.sort(key=lambda x: x[0], reverse=True) for year, position in results: info += f" {year}: {position}\n" info += "\n" return info except Exception as e: return f"Error retrieving Monument results for rider ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_rider_team_and_ranking", description="""Get information about a professional cyclist's team affiliations and UCI rankings throughout their career. This tool retrieves the rider's team history and their UCI ranking points over time. It provides a comprehensive overview of their professional career progression through different teams and their performance in the UCI rankings. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get team and ranking history for Tadej Pogačar (ID: 16973) - Get team and ranking history for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Complete team history with years - UCI ranking positions and points - Career progression timeline - Current team and ranking status""" ) async def get_rider_team_and_ranking(rider_id: int) -> str: """Get information about a professional cyclist's team affiliations and UCI rankings throughout their career. This tool retrieves the rider's team history and their UCI ranking points over time. It provides a comprehensive overview of their professional career progression through different teams and their performance in the UCI rankings. Args: rider_id: The FirstCycling rider ID (e.g., 16973 for Tadej Pogačar) """ try: # Create a rider instance rider = Rider(rider_id) # Get team and ranking information team_ranking = rider.team_and_ranking() # Build information string info = "" # Add rider name if available from header details if hasattr(team_ranking, 'header_details') and team_ranking.header_details and 'name' in team_ranking.header_details: rider_name = team_ranking.header_details['name'] info += f"Team and Ranking History for {rider_name}:\n\n" else: # Try to extract rider name from page title if hasattr(team_ranking, 'soup'): title = team_ranking.soup.find('title') if title and '|' in title.text: rider_name = title.text.split('|')[0].strip() info += f"Team and Ranking History for {rider_name}:\n\n" else: info += f"Team and Ranking History for Rider ID {rider_id}:\n\n" else: info += f"Team and Ranking History for Rider ID {rider_id}:\n\n" # Check if we need to use the default parsing or direct HTML parsing if hasattr(team_ranking, 'results_df') and not (team_ranking.results_df is None or team_ranking.results_df.empty): # Use the default parsed results results_df = team_ranking.results_df # Sort by year (most recent first) results_df = results_df.sort_values('Year', ascending=False) # Group by year for year in results_df['Year'].unique(): year_data = results_df[results_df['Year'] == year] info += f"{year}:\n" # Get team information team = year_data['Team'].iloc[0] if not year_data['Team'].empty else 'N/A' info += f" Team: {team}\n" # Get ranking information ranking = year_data['Ranking'].iloc[0] if not year_data['Ranking'].empty else 'N/A' points = year_data['Points'].iloc[0] if not year_data['Points'].empty else 'N/A' if ranking != 'N/A' or points != 'N/A': info += " UCI Ranking: " if ranking != 'N/A': info += f"{ranking}" if points != 'N/A': info += f" ({points} points)" info += "\n" info += "\n" else: # Direct HTML parsing if results_df is not available if not hasattr(team_ranking, 'soup'): return f"No team and ranking information found for rider ID {rider_id}. This rider ID may not exist." soup = team_ranking.soup # Look for team and ranking information in tables tables = soup.find_all('table') stats_table = None # Find the table with team and ranking information # Usually, it's a table with "Year", "Team", "Ranking", "Points" headers for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and "Year" in headers and "Team" in headers: stats_table = table break if stats_table is None: return f"No team and ranking information could be found for rider ID {rider_id}." # Parse the table rows rows = stats_table.find_all('tr') # Skip the header row data = [] for row in rows[1:]: cols = row.find_all('td') if len(cols) >= 3: # Ensure we have enough columns year = cols[0].text.strip() # Extract team (might be in a link) team_col = cols[1] team_link = team_col.find('a') team = team_link.text.strip() if team_link else team_col.text.strip() # Extract ranking and points # (format can vary but typically in columns 2 and 3) ranking = cols[2].text.strip() if len(cols) > 2 else 'N/A' points = cols[3].text.strip() if len(cols) > 3 else 'N/A' data.append({ 'Year': year, 'Team': team, 'Ranking': ranking, 'Points': points }) # Sort by year (most recent first) data.sort(key=lambda x: x['Year'], reverse=True) # Build the information string for item in data: info += f"{item['Year']}:\n" info += f" Team: {item['Team']}\n" if item['Ranking'] != 'N/A' or item['Points'] != 'N/A': info += " UCI Ranking: " if item['Ranking'] != 'N/A': info += f"{item['Ranking']}" if item['Points'] != 'N/A': info += f" ({item['Points']} points)" info += "\n" info += "\n" if not data: return f"No team and ranking information could be parsed for rider ID {rider_id}." return info except Exception as e: return f"An error occurred while getting team and ranking information for rider ID {rider_id}: {str(e)}" @mcp.tool( name="get_rider_race_history", description="""Get the complete race history of a professional cyclist, optionally filtered by year. This tool retrieves a comprehensive list of all races the rider has participated in, including their positions, times, and race categories. It provides a detailed overview of their racing career. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get complete race history for Tadej Pogačar (ID: 16973) - Get 2023 race history for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - All races organized by year - Position and time for each race - Race category and details - Chronological organization""" ) async def get_rider_race_history(rider_id: int, year: int = None) -> str: """Get the complete race history of a professional cyclist, optionally filtered by year. This tool retrieves a comprehensive list of all races the rider has participated in, including their positions, times, and race categories. It provides a detailed overview of their racing career. Args: rider_id: The FirstCycling rider ID (e.g., 16973 for Tadej Pogačar) year: Optional year to filter results (e.g., 2023). If not provided, returns all years. """ try: # Create a rider instance rider = Rider(rider_id) # Get race history race_history = rider.race_history() # Build information string info = "" # Get rider name rider_name = None if hasattr(race_history, 'header_details') and race_history.header_details and 'name' in race_history.header_details: rider_name = race_history.header_details['name'] else: # Try to extract rider name from page title if hasattr(race_history, 'soup') and race_history.soup: title = race_history.soup.find('title') if title and '|' in title.text: rider_name = title.text.split('|')[0].strip() # Format title if rider_name: info += f"Race History for {rider_name}" else: info += f"Race History for Rider ID {rider_id}" if year: info += f" ({year})" info += ":\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(race_history, 'results_df') and not (race_history.results_df is None or race_history.results_df.empty): # Use standard parsing results_df = race_history.results_df # Filter by year if specified if year: results_df = results_df[results_df['Year'] == year] # Sort by date (most recent first) results_df = results_df.sort_values('Date', ascending=False) # Group by year for year_val in results_df['Year'].unique(): year_data = results_df[results_df['Year'] == year_val] info += f"{year_val}:\n" for _, row in year_data.iterrows(): date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') pos = row.get('Pos', 'N/A') category = row.get('CAT', 'N/A') time = row.get('Time', '') result_line = f" {date} - {race} ({category}): {pos}" if time: result_line += f" - {time}" info += result_line + "\n" info += "\n" else: # Direct HTML parsing if not hasattr(race_history, 'soup') or not race_history.soup: return f"No race history found for rider ID {rider_id}. This rider ID may not exist." soup = race_history.soup # Find race history table tables = soup.find_all('table') race_table = None # First try to find tables with specific headers for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and any(("Date" in h or "Race" in h or "Pos" in h) for h in headers): race_table = table break # If not found, look for any table that might contain race data if not race_table and tables: # Try to find a table with typical race data structure (multiple rows with dates, etc.) for table in tables: rows = table.find_all('tr') if len(rows) >= 3: # Header row + at least 2 data rows # Check if any cell in the first row contains date-like text first_row_cells = rows[1].find_all('td') for cell in first_row_cells: cell_text = cell.text.strip() # Look for date patterns like DD.MM or YYYY or MM/DD if (len(cell_text) >= 4 and ('.' in cell_text or '/' in cell_text or '-' in cell_text or (cell_text.isdigit() and int(cell_text) > 2000 and int(cell_text) < 2030))): race_table = table break if race_table: break # If we still couldn't find a table, direct URL request to races page if not race_table: # Try to directly access the races page races_url = f"https://firstcycling.com/rider.php?r={rider_id}&races=2" try: races_response = requests.get(races_url) if races_response.status_code == 200: races_soup = BeautifulSoup(races_response.text, 'html.parser') tables = races_soup.find_all('table') # Look for tables with race data for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and any(keyword in ' '.join(headers).lower() for keyword in ['date', 'race', 'result', 'position']): race_table = table break except Exception as table_error: # If direct access fails, continue with the original soup pass if not race_table: # Get the rider name for a more helpful error message rider_name_text = "" try: if hasattr(race_history, 'soup'): name_element = race_history.soup.find('h1') if name_element: rider_name_text = f" ({name_element.text.strip()})" except: pass return f"Could not find race history table for rider ID {rider_id}{rider_name_text}. The data may not be available on FirstCycling." # Parse race data rows = race_table.find_all('tr') race_data = [] # Get column indices from header row headers = [th.text.strip() for th in rows[0].find_all('th')] if rows and rows[0].find_all('th') else [] # Determine column positions, with fallbacks if headers aren't clear date_idx = next((i for i, h in enumerate(headers) if "Date" in h), 0) # Default to first column race_idx = next((i for i, h in enumerate(headers) if "Race" in h), 1) # Default to second column pos_idx = next((i for i, h in enumerate(headers) if "Pos" in h or "Result" in h), 2) # Default to third column cat_idx = next((i for i, h in enumerate(headers) if "CAT" in h or "Category" in h), None) # May not exist # Skip header row if it exists start_row = 1 if headers else 0 for row in rows[start_row:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data date_text = cols[date_idx].text.strip() if date_idx is not None and date_idx < len(cols) else "N/A" race_text = cols[race_idx].text.strip() if race_idx is not None and race_idx < len(cols) else "N/A" pos_text = cols[pos_idx].text.strip() if pos_idx is not None and pos_idx < len(cols) else "N/A" cat_text = cols[cat_idx].text.strip() if cat_idx is not None and cat_idx < len(cols) else "N/A" # Extract year from date (format may vary, but often includes year) race_year = None if date_text != "N/A": # Try common date formats to extract year if len(date_text) >= 4: try: # If year is last part (e.g., "01.01.2023") race_year = int(date_text[-4:]) except Exception: # If year is first part (e.g., "2023-01-01") try: race_year = int(date_text[:4]) except Exception: pass if race_year: # Skip if a specific year was requested and this race is from a different year if year and race_year != year: continue race_data.append({ 'Year': race_year, 'Date': date_text, 'Race': race_text, 'Pos': pos_text, 'CAT': cat_text }) # Group by year year_grouped = {} for race in race_data: year_val = race['Year'] if year_val not in year_grouped: year_grouped[year_val] = [] year_grouped[year_val].append(race) # Sort years (most recent first) for year_val in sorted(year_grouped.keys(), reverse=True): races = year_grouped[year_val] info += f"{year_val}:\n" for race in races: result_line = f" {race['Date']} - {race['Race']} ({race['CAT']}): {race['Pos']}" info += result_line + "\n" info += "\n" if not race_data: info += "No race history found for this rider.\n" return info except Exception as e: return f"Error searching for riders: {str(e)}" @mcp.tool( name="search_race", description="""Search for cycling races by name. This tool helps find races by their name, returning a list of matching races with their IDs and countries. This is useful when you know a race's name but need its ID for other operations. Example usage: - Search for "tour" to find Tour de France and other tours - Search for "giro" to find Giro d'Italia Returns a formatted string with: - List of matching races - Each race's ID, name, and country - Number of matches found""" ) async def search_race(query: str) -> str: """Search for races by name. Args: query (str): The search query string to find races by name. Returns: str: A formatted string containing matching races with their details: - Race ID - Race name - Country """ try: # Search for races races = Race.search(query) if not races: return f"No races found matching the query '{query}'." # Build results string info = f"Found {len(races)} races matching '{query}':\n\n" for race in races: info += f"ID: {race['id']}\n" info += f"Name: {race['name']}\n" if race['country']: info += f"Country: {race['country'].upper()}\n" info += "\n" return info except Exception as e: return f"Error searching for races: {str(e)}" @mcp.tool( name="get_rider_one_day_races", description="""Get a rider's results in one-day races, optionally filtered by year. This tool retrieves detailed information about a rider's performance in one-day races (classics and one-day events). It provides comprehensive data about positions, times, and race categories. Results can be filtered by a specific year. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get one-day race results for Mathieu van der Poel (ID: 16672) - Get 2023 one-day race results for Wout van Aert (ID: 16948) Returns a formatted string with: - Results in one-day races organized by year - Position and time for each race - Race category and details - Chronological organization""" ) async def get_rider_one_day_races(rider_id: int, year: int = None) -> str: """Get a rider's results in one-day races, optionally filtered by year. This tool retrieves detailed information about a rider's performance in one-day races (classics and one-day events). It provides comprehensive data about positions, times, and race categories. Results can be filtered by a specific year. Args: rider_id: The FirstCycling rider ID (e.g., 16672 for Mathieu van der Poel) year: Optional year to filter results (e.g., 2023). If not provided, returns all years. """ try: # Create a rider instance rider = Rider(rider_id) # Get one-day races results one_day_results = rider.one_day_races() # Build information string info = "" # Get rider name rider_name = None if hasattr(one_day_results, 'header_details') and one_day_results.header_details and 'name' in one_day_results.header_details: rider_name = one_day_results.header_details['name'] else: # Try to extract rider name from page title if hasattr(one_day_results, 'soup') and one_day_results.soup: title = one_day_results.soup.find('title') if title and '|' in title.text: rider_name = title.text.split('|')[0].strip() # Format title if rider_name: info += f"One-Day Race Results for {rider_name}" else: info += f"One-Day Race Results for Rider ID {rider_id}" if year: info += f" ({year})" info += ":\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(one_day_results, 'results_df') and not (one_day_results.results_df is None or one_day_results.results_df.empty): # Use standard parsing results_df = one_day_results.results_df # Filter by year if specified if year: results_df = results_df[results_df['Year'] == year] # Sort by year (most recent first) results_df = results_df.sort_values('Year', ascending=False) # Group by year for year_val in results_df['Year'].unique(): year_data = results_df[results_df['Year'] == year_val] info += f"{year_val}:\n" # Sort by date within year year_data = year_data.sort_values('Date', ascending=False) for _, row in year_data.iterrows(): date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') pos = row.get('Pos', 'N/A') category = row.get('CAT', 'N/A') info += f" {date} - {race} ({category}): {pos}\n" info += "\n" else: # Direct HTML parsing if not hasattr(one_day_results, 'soup') or not one_day_results.soup: return f"No one-day race results found for rider ID {rider_id}. This rider ID may not exist." soup = one_day_results.soup # Find one-day races results table tables = soup.find_all('table') results_table = None # Look for the appropriate table that contains one-day races results for table in tables: # Check table headers to find the right one headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and "Race" in headers and ("Date" in headers or "Year" in headers): results_table = table break if not results_table: return f"Could not find one-day race results table for rider ID {rider_id}." # Parse one-day races data rows = results_table.find_all('tr') race_data = [] # Get column indices from header row headers = [th.text.strip() for th in rows[0].find_all('th')] # Find the indices of key columns year_idx = next((i for i, h in enumerate(headers) if "Year" in h), None) date_idx = next((i for i, h in enumerate(headers) if "Date" in h), None) race_idx = next((i for i, h in enumerate(headers) if "Race" in h), None) pos_idx = next((i for i, h in enumerate(headers) if "Pos" in h), None) cat_idx = next((i for i, h in enumerate(headers) if "CAT" in h), None) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data race_year = cols[year_idx].text.strip() if year_idx is not None and year_idx < len(cols) else None # If we don't have a year column, try to extract from date if race_year is None and date_idx is not None and date_idx < len(cols): date_text = cols[date_idx].text.strip() # Try to extract year from date format (e.g., 01.01.2023 or 2023-01-01) try: if len(date_text) >= 4: if date_text[-4:].isdigit(): race_year = date_text[-4:] elif date_text[:4].isdigit(): race_year = date_text[:4] except Exception: pass # If we still don't have a year, use the next row if race_year is None or not race_year.isdigit(): continue # Convert year to int for comparison race_year_int = int(race_year) # Skip if a specific year was requested and this race is from a different year if year and race_year_int != year: continue date_text = cols[date_idx].text.strip() if date_idx is not None and date_idx < len(cols) else "N/A" race_text = cols[race_idx].text.strip() if race_idx is not None and race_idx < len(cols) else "N/A" pos_text = cols[pos_idx].text.strip() if pos_idx is not None and pos_idx < len(cols) else "N/A" cat_text = cols[cat_idx].text.strip() if cat_idx is not None and cat_idx < len(cols) else "N/A" race_data.append({ 'Year': race_year_int, 'Date': date_text, 'Race': race_text, 'Pos': pos_text, 'CAT': cat_text }) # Group by year year_grouped = {} for race in race_data: year_val = race['Year'] if year_val not in year_grouped: year_grouped[year_val] = [] year_grouped[year_val].append(race) # Sort years (most recent first) for year_val in sorted(year_grouped.keys(), reverse=True): races = year_grouped[year_val] info += f"{year_val}:\n" # Sort by date within year (can be complex due to different date formats) # For now, just display as is for race in races: info += f" {race['Date']} - {race['Race']} ({race['CAT']}): {race['Pos']}\n" info += "\n" if not race_data: info += "No one-day race results found for this rider.\n" return info except Exception as e: return f"Error retrieving one-day race results for rider ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_rider_stage_races", description="""Get a rider's results in stage races, optionally filtered by year. This tool retrieves detailed information about a rider's performance in stage races (multi-day races like Tour de France, Giro d'Italia, etc.). It provides comprehensive data about positions, times, and race categories. Results can be filtered by a specific year. Note: If you don't know the rider's ID, use the search_rider tool first to find it by name. Example usage: - Get stage race results for Tadej Pogačar (ID: 16973) - Get 2023 stage race results for Jonas Vingegaard (ID: 16974) Returns a formatted string with: - Results in stage races organized by year - Position and time for each race - Race category and details - Chronological organization""" ) async def get_rider_stage_races(rider_id: int, year: int = None) -> str: """Get a rider's results in stage races, optionally filtered by year. This tool retrieves detailed information about a rider's performance in stage races (multi-day races like Tour de France, Giro d'Italia, etc.). It provides comprehensive data about positions, times, and race categories. Results can be filtered by a specific year. Args: rider_id: The FirstCycling rider ID (e.g., 16973 for Tadej Pogačar) year: Optional year to filter results (e.g., 2023). If not provided, returns all years. """ try: # Create a rider instance rider = Rider(rider_id) # Get stage races results stage_results = rider.stage_races() # Build information string info = "" # Get rider name rider_name = None if hasattr(stage_results, 'header_details') and stage_results.header_details and 'name' in stage_results.header_details: rider_name = stage_results.header_details['name'] else: # Try to extract rider name from page title if hasattr(stage_results, 'soup') and stage_results.soup: title = stage_results.soup.find('title') if title and '|' in title.text: rider_name = title.text.split('|')[0].strip() # Format title if rider_name: info += f"Stage Race Results for {rider_name}" else: info += f"Stage Race Results for Rider ID {rider_id}" if year: info += f" ({year})" info += ":\n\n" # Check if we need to use standard parsing or direct HTML parsing if hasattr(stage_results, 'results_df') and not (stage_results.results_df is None or stage_results.results_df.empty): # Use standard parsing results_df = stage_results.results_df # Filter by year if specified if year: results_df = results_df[results_df['Year'] == year] # Sort by year (most recent first) results_df = results_df.sort_values('Year', ascending=False) # Group by year for year_val in results_df['Year'].unique(): year_data = results_df[results_df['Year'] == year_val] info += f"{year_val}:\n" # Sort by date within year year_data = year_data.sort_values('Date', ascending=False) for _, row in year_data.iterrows(): date = row.get('Date', 'N/A') race = row.get('Race', 'N/A') pos = row.get('Pos', 'N/A') category = row.get('CAT', 'N/A') info += f" {date} - {race} ({category}): {pos}\n" info += "\n" else: # Direct HTML parsing if not hasattr(stage_results, 'soup') or not stage_results.soup: return f"No stage race results found for rider ID {rider_id}. This rider ID may not exist." soup = stage_results.soup # Find stage races results table tables = soup.find_all('table') results_table = None # Look for the appropriate table that contains stage races results for table in tables: # Check table headers to find the right one headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and "Race" in headers and ("Date" in headers or "Year" in headers): results_table = table break if not results_table: return f"Could not find stage race results table for rider ID {rider_id}." # Parse stage races data rows = results_table.find_all('tr') race_data = [] # Get column indices from header row headers = [th.text.strip() for th in rows[0].find_all('th')] # Find the indices of key columns year_idx = next((i for i, h in enumerate(headers) if "Year" in h), None) date_idx = next((i for i, h in enumerate(headers) if "Date" in h), None) race_idx = next((i for i, h in enumerate(headers) if "Race" in h), None) pos_idx = next((i for i, h in enumerate(headers) if "Pos" in h), None) cat_idx = next((i for i, h in enumerate(headers) if "CAT" in h), None) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue # Extract data race_year = cols[year_idx].text.strip() if year_idx is not None and year_idx < len(cols) else None # If we don't have a year column, try to extract from date if race_year is None and date_idx is not None and date_idx < len(cols): date_text = cols[date_idx].text.strip() # Try to extract year from date format (e.g., 01.01.2023 or 2023-01-01) try: if len(date_text) >= 4: if date_text[-4:].isdigit(): race_year = date_text[-4:] elif date_text[:4].isdigit(): race_year = date_text[:4] except Exception: pass # If we still don't have a year, use the next row if race_year is None or not race_year.isdigit(): continue # Convert year to int for comparison race_year_int = int(race_year) # Skip if a specific year was requested and this race is from a different year if year and race_year_int != year: continue date_text = cols[date_idx].text.strip() if date_idx is not None and date_idx < len(cols) else "N/A" race_text = cols[race_idx].text.strip() if race_idx is not None and race_idx < len(cols) else "N/A" pos_text = cols[pos_idx].text.strip() if pos_idx is not None and pos_idx < len(cols) else "N/A" cat_text = cols[cat_idx].text.strip() if cat_idx is not None and cat_idx < len(cols) else "N/A" race_data.append({ 'Year': race_year_int, 'Date': date_text, 'Race': race_text, 'Pos': pos_text, 'CAT': cat_text }) # Group by year year_grouped = {} for race in race_data: year_val = race['Year'] if year_val not in year_grouped: year_grouped[year_val] = [] year_grouped[year_val].append(race) # Sort years (most recent first) for year_val in sorted(year_grouped.keys(), reverse=True): races = year_grouped[year_val] info += f"{year_val}:\n" # Sort by date within year (can be complex due to different date formats) # For now, just display as is for race in races: info += f" {race['Date']} - {race['Race']} ({race['CAT']}): {race['Pos']}\n" info += "\n" if not race_data: info += "No stage race results found for this rider.\n" return info except Exception as e: return f"Error retrieving stage race results for rider ID {rider_id}: {str(e)}. The rider ID may not exist or there might be a connection issue." @mcp.tool( name="get_race_details", description="""Get comprehensive details about a cycling race. This tool provides detailed information about a specific race, including its history, key statistics, route details, and other relevant information. The data can be filtered by specific classification. Note: If you don't know the race's ID, use the search_race tool first to find it by name. Example usage: - Get details for Tour de France (ID: 17) - Get details for Paris-Roubaix (ID: 30) Returns a formatted string with: - Race name, country, and category - Historical information and key statistics - Course details and characteristics - Optional classification details""" ) async def get_race_details(race_id: int, classification_num: int = None) -> str: """Get comprehensive details about a cycling race. Args: race_id: The FirstCycling race ID (e.g., 17 for Tour de France) classification_num: Optional parameter to specify the classification (e.g., 1 for General Classification) """ try: # Create a race instance race = Race(race_id) # Get race overview race_overview = race.overview(classification_num) # Build information string info = "" # Check if we can parse the data if not hasattr(race_overview, 'soup') or not race_overview.soup: return f"No race details found for race ID {race_id}. This race ID may not exist." soup = race_overview.soup # Extract race name from title title = soup.find('title') race_name = title.text.split('|')[0].strip() if title and '|' in title.text else f"Race ID {race_id}" info += f"Race Details for {race_name}:\n\n" # Extract basic information basic_info = {} # Look for tables with race info tables = soup.find_all('table') info_table = None for table in tables: if 'class' in table.attrs and 'basic' in table['class']: info_table = table break if info_table: rows = info_table.find_all('tr') for row in rows: cols = row.find_all('td') if len(cols) >= 2: key = cols[0].text.strip().rstrip(':') value = cols[1].text.strip() if key and value: basic_info[key] = value # Format basic information if basic_info: info += "Basic Information:\n" for key, value in basic_info.items(): info += f" {key}: {value}\n" info += "\n" # Look for course/race description description_div = soup.find('div', class_='w3-padding') if description_div: description_text = description_div.text.strip() if description_text: info += "Description:\n" info += f" {description_text}\n\n" # Look for winners/podium information winners_table = None for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 2 and ("Year" in headers or "Edition" in headers) and "Winner" in headers: winners_table = table break if winners_table: info += "Recent Winners:\n" rows = winners_table.find_all('tr') # Skip header row for i, row in enumerate(rows[1:]): if i >= 5: # Limit to last 5 winners break cols = row.find_all('td') if len(cols) >= 2: year = cols[0].text.strip() winner = cols[1].text.strip() info += f" {year}: {winner}\n" info += "\n" # If standard parsing doesn't work, try direct HTML parsing if not basic_info and not description_div and not winners_table: # Look for any useful information paragraphs = soup.find_all('p') for p in paragraphs: p_text = p.text.strip() if len(p_text) > 50: # Only include substantial paragraphs info += f"{p_text}\n\n" # Extract any header information headers = soup.find_all(['h1', 'h2', 'h3']) for header in headers: header_text = header.text.strip() if race_name not in header_text: # Avoid duplicating the race name info += f"{header_text}\n" # Get the next element if it's a paragraph next_element = header.find_next_sibling() if next_element and next_element.name == 'p': p_text = next_element.text.strip() if p_text: info += f" {p_text}\n\n" if info == f"Race Details for {race_name}:\n\n": return f"Could not find specific details for race ID {race_id}." return info except Exception as e: return f"Error retrieving race details for race ID {race_id}: {str(e)}" @mcp.tool( name="get_race_edition_results", description="""Get detailed results for a specific edition of a cycling race. This tool provides comprehensive results for a particular edition of a race, including rankings, time gaps, and other relevant statistics. Results can be filtered by classification or stage. Note: If you don't know the race's ID, use the search_race tool first to find it by name. Example usage: - Get 2023 Tour de France general classification results (Race ID: 17, Year: 2023) - Get 2022 Paris-Roubaix results (Race ID: 30, Year: 2022) - Get results for stage 5 of 2023 Tour de France (Race ID: 17, Year: 2023, Stage: 5) Returns a formatted string with: - Race name, year, and category - Complete result list with rankings and time gaps - Rider names and teams - Classification or stage specific information""" ) async def get_race_edition_results(race_id: int, year: int, classification_num: int = None, stage_num: int = None) -> str: """Get detailed results for a specific edition of a cycling race. Args: race_id: The FirstCycling race ID (e.g., 17 for Tour de France) year: The year of the race edition (e.g., 2023) classification_num: Optional parameter to specify the classification (e.g., 1 for General Classification) stage_num: Optional parameter to specify the stage number (e.g., 5 for stage 5) """ try: # Create a race instance race = Race(race_id) # Get specific edition race_edition = race.edition(year) # Get results results = race_edition.results(classification_num, stage_num) # Build information string info = "" # Check if we can parse the data if not hasattr(results, 'soup') or not results.soup: return f"No results found for race ID {race_id}, year {year}. The race may not have been held that year." soup = results.soup # Extract race name from title title = soup.find('title') race_name = title.text.split('|')[0].strip() if title and '|' in title.text else f"Race ID {race_id}" # Format title based on parameters info += f"{year} {race_name}" if stage_num is not None: info += f" - Stage {stage_num}" elif classification_num is not None: classification_names = { 1: "General Classification", 2: "Points Classification", 3: "Mountains Classification", 4: "Youth Classification", 5: "Team Classification" } if classification_num in classification_names: info += f" - {classification_names[classification_num]}" info += " Results:\n\n" # Check if we have results DataFrame if hasattr(results, 'results_df') and not (results.results_df is None or results.results_df.empty): # Use standard parsing results_df = results.results_df # Get top results (limit to 20 for readability) results_df = results_df.head(20) if len(results_df) > 20 else results_df for _, row in results_df.iterrows(): pos = row.get('Pos', 'N/A') rider = row.get('Rider', 'N/A') team = row.get('Team', 'N/A') time = row.get('Time', 'N/A') result_line = f"{pos}. {rider} ({team})" if time and time != 'N/A': result_line += f" - {time}" info += result_line + "\n" if len(results_df) == 20: info += "...\n" else: # Direct HTML parsing # Find results table results_table = None tables = soup.find_all('table') for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and "Pos" in headers: results_table = table break if not results_table: return f"Could not find results table for race ID {race_id}, year {year}." # Parse results rows = results_table.find_all('tr') # Get column indices headers = [th.text.strip() for th in rows[0].find_all('th')] pos_idx = next((i for i, h in enumerate(headers) if "Pos" in h), 0) rider_idx = next((i for i, h in enumerate(headers) if "Rider" in h), 1) team_idx = next((i for i, h in enumerate(headers) if "Team" in h), 2) time_idx = next((i for i, h in enumerate(headers) if "Time" in h), 3) # Skip header row and limit to 20 results max_rows = min(21, len(rows)) for row in rows[1:max_rows]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue pos = cols[pos_idx].text.strip() if pos_idx < len(cols) else "N/A" # Rider name can be in a link rider_col = cols[rider_idx] if rider_idx < len(cols) else None rider = rider_col.text.strip() if rider_col else "N/A" # Team can be in a link team_col = cols[team_idx] if team_idx < len(cols) else None team = team_col.text.strip() if team_col else "N/A" time = cols[time_idx].text.strip() if time_idx < len(cols) and time_idx < len(cols) else "N/A" result_line = f"{pos}. {rider} ({team})" if time and time != 'N/A': result_line += f" - {time}" info += result_line + "\n" if len(rows) > 21: info += "...\n" return info except Exception as e: return f"Error retrieving race results for race ID {race_id}, year {year}: {str(e)}" @mcp.tool( name="get_start_list", description="""Get the start list for a specific edition of a cycling race. The start list includes rider numbers, names, and teams. Note: If you don't know the race's ID, use the search_race tool first to find it by name. If no year is specified, the current year will be used. Example usage: - Get start list for current year's Tour de France (Race ID: 17) - Get start list for 2023 Paris-Roubaix (Race ID: 30, Year: 2023) Returns a formatted string with: - Race name and year - List of participating teams - Riders for each team with their race numbers""" ) async def get_start_list(race_id: int, year: int = None) -> str: """Get the start list for a specific edition of a cycling race. Args: race_id: The FirstCycling race ID year: The year of the race edition (defaults to current year if not specified) """ if year is None: year = datetime.now().year try: # Create a race instance race = Race(race_id) # Get specific edition race_edition = race.edition(year) # Get start list start_list = race_edition.startlist() # Build information string info = "" # Check if we can parse the data if not hasattr(start_list, 'soup') or not start_list.soup: return f"No start list found for race ID {race_id}, year {year}. The race may not have a published start list yet." soup = start_list.soup # Extract race name from title title = soup.find('title') race_name = title.text.split('|')[0].strip() if title and '|' in title.text else f"Race ID {race_id}" # Add header info += f"{year} {race_name} - Start List:\n\n" # Find all team tables team_tables = soup.find_all('table', {'class': 'tablesorter'}) if not team_tables: return f"No start list tables found for race ID {race_id}, year {year}." # Process each team table for table in team_tables: # Get team name from header team_header = table.find('th') if not team_header: continue # Extract team name from the link team_link = team_header.find('a') if not team_link: continue team_name = team_link.text.strip() info += f"\n{team_name}:\n" # Process riders for row in table.find('tbody').find_all('tr'): cols = row.find_all('td') if len(cols) < 2: # Should have number and rider name continue number = cols[0].text.strip() rider_link = cols[1].find('a') if not rider_link: continue # Check if rider is crossed out (not starting) is_not_starting = 'text-decoration:line-through' in rider_link.get('style', '') # Get rider name parts (last name in uppercase, first name in small tag) last_name = rider_link.text first_name_tag = rider_link.find('span', class_='small') if first_name_tag: # Remove the first name part from the full text to get the last name last_name = last_name.replace(first_name_tag.text, '').strip() first_name = first_name_tag.text.strip() else: first_name = '' last_name = last_name.strip() # Get nationality flag = cols[1].find('span', class_=lambda x: x and x.startswith('flag flag-')) nationality = flag['class'][1].replace('flag-', '').upper() if flag else '' # Format rider line with both first and last names rider_line = f"{number}. {first_name} {last_name}" if nationality: rider_line += f" ({nationality})" if is_not_starting: rider_line += " [NOT STARTING]" info += rider_line + "\n" return info except Exception as e: return f"Error retrieving start list for race ID {race_id}, year {year}: {str(e)}" @mcp.tool( name="get_race_victory_table", description="""Get the all-time victory table for a cycling race. This tool provides a historical summary of the most successful riders in a specific race, showing the number of victories for each rider throughout the race's history. Note: If you don't know the race's ID, use the search_race tool first to find it by name. Example usage: - Get victory table for Tour de France (ID: 17) - Get victory table for Paris-Roubaix (ID: 30) Returns a formatted string with: - Race name - List of riders with the most victories - Number of victories for each rider - Years of victories where available""" ) async def get_race_victory_table(race_id: int) -> str: """Get the all-time victory table for a cycling race. Args: race_id: The FirstCycling race ID (e.g., 17 for Tour de France) """ try: # Create a race instance race = Race(race_id) # Get victory table victory_table = race.victory_table() # Build information string info = "" # Check if we can parse the data if not hasattr(victory_table, 'soup') or not victory_table.soup: return f"No victory table found for race ID {race_id}. This race ID may not exist." soup = victory_table.soup # Extract race name from title title = soup.find('title') race_name = title.text.split('|')[0].strip() if title and '|' in title.text else f"Race ID {race_id}" info += f"Victory Table for {race_name}:\n\n" # Check if we have results DataFrame if hasattr(victory_table, 'results_df') and not (victory_table.results_df is None or victory_table.results_df.empty): # Use standard parsing results_df = victory_table.results_df # Get top entries (limit to 20 for readability) results_df = results_df.head(20) if len(results_df) > 20 else results_df for i, (_, row) in enumerate(results_df.iterrows()): pos = i + 1 rider = row.get('Rider', 'N/A') wins = row.get('Wins', 'N/A') years = row.get('Years', '') result_line = f"{pos}. {rider}: {wins} win" if wins != '1' and wins != 1: result_line += "s" if years: result_line += f" ({years})" info += result_line + "\n" else: # Direct HTML parsing # Find victory table victory_table_el = None tables = soup.find_all('table') for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if any(header in ' '.join(headers) for header in ['Wins', 'Victory', 'Victories']): victory_table_el = table break if not victory_table_el: return f"Could not find victory table for race ID {race_id}." # Parse victory data rows = victory_table_el.find_all('tr') # Get column indices headers = [th.text.strip() for th in rows[0].find_all('th')] rider_idx = next((i for i, h in enumerate(headers) if "Rider" in h), 0) wins_idx = next((i for i, h in enumerate(headers) if "Wins" in h or "Victories" in h), 1) years_idx = next((i for i, h in enumerate(headers) if "Years" in h or "Year" in h), 2) # Skip header row and limit to 20 entries max_rows = min(21, len(rows)) for i, row in enumerate(rows[1:max_rows]): cols = row.find_all('td') if len(cols) < 2: # Ensure it's a data row continue pos = i + 1 # Rider name can be in a link rider_col = cols[rider_idx] if rider_idx < len(cols) else None rider = rider_col.text.strip() if rider_col else "N/A" wins = cols[wins_idx].text.strip() if wins_idx < len(cols) else "N/A" years = cols[years_idx].text.strip() if years_idx < len(cols) and years_idx < len(cols) else "" result_line = f"{pos}. {rider}: {wins} win" if wins != '1': result_line += "s" if years: result_line += f" ({years})" info += result_line + "\n" if len(rows) > 21: info += "...\n" return info except Exception as e: return f"Error retrieving victory table for race ID {race_id}: {str(e)}" @mcp.tool( name="get_uci_rankings", description="""Get UCI rankings for riders, teams, or nations. This tool provides access to the UCI ranking data for professional cyclists, teams, or nations. Results can be filtered by ranking type, year, and category. Example usage: - Get World UCI rider rankings for 2023 - Get Europe Tour UCI team rankings for 2022 - Get UCI nation rankings for 2023 in the World category Returns a formatted string with: - Ranking list with positions and points - Filtered by specified categories - Organized in a readable format - Option to filter by country""" ) async def get_uci_rankings(rank_type: str = "riders", category: str = "world", year: int = None, country_code: str = None, page_num: int = 1) -> str: """Get UCI rankings for riders, teams, or nations. Args: rank_type: The type of ranking to retrieve (riders, teams, or nations) category: The UCI ranking category (world, one-day, stage, europe, america, asia, africa, oceania) year: The year for the rankings (defaults to current year if None) country_code: Optional three-letter code to filter by country (e.g., "BEL" for Belgium) page_num: The page number for the results (default is 1) """ try: # Map rank_type to h parameter h_params = { "riders": 1, "teams": 2, "nations": 3 } # Map category to rank parameter rank_params = { "world": 1, "one-day": 2, "stage": 3, "africa": 4, "america": 5, "europe": 6, "asia": 7, "oceania": 8, "women": 99 } # Get the parameter values h = h_params.get(rank_type.lower(), 1) # Default to riders rank = rank_params.get(category.lower(), 1) # Default to world # Create parameters dict params = { "h": h, "rank": rank, "page_num": page_num } # Add optional parameters if year: params["y"] = year if country_code: params["cnat"] = country_code.upper() # Get rankings rankings = Ranking(**params) # Build information string info = "" # Format title category_name = category.capitalize() rank_type_name = rank_type.capitalize() # Get year string year_str = str(year) if year else "Current" # Build title info += f"UCI {category_name} {rank_type_name} Rankings - {year_str}" if country_code: info += f" ({country_code.upper()})" info += f" - Page {page_num}:\n\n" # Check if we can parse the data if not hasattr(rankings, 'soup') or not rankings.soup: return f"No UCI rankings found for the specified parameters." soup = rankings.soup # Find rankings table rankings_table = None tables = soup.find_all('table') for table in tables: headers = [th.text.strip() for th in table.find_all('th')] if len(headers) >= 3 and ("Rank" in headers or "Pos" in headers or "Ranking" in headers): rankings_table = table break if not rankings_table: # Try to find any table with ranking-like structure for table in tables: rows = table.find_all('tr') if len(rows) >= 3: # Header + at least 2 data rows # Check if first cell might be a position/rank first_row_cols = rows[1].find_all('td') if len(first_row_cols) >= 3 and first_row_cols[0].text.strip().isdigit(): rankings_table = table break if not rankings_table: return f"Could not find rankings table for the specified parameters." # Parse rankings data rows = rankings_table.find_all('tr') # Get headers to determine column positions headers = [th.text.strip() for th in rows[0].find_all('th')] if rows[0].find_all('th') else [] # Find column indices based on rank type if rank_type.lower() == "riders": pos_idx = next((i for i, h in enumerate(headers) if "Rank" in h or "Pos" in h), 0) name_idx = next((i for i, h in enumerate(headers) if "Rider" in h or "Name" in h), 1) team_idx = next((i for i, h in enumerate(headers) if "Team" in h), 2) points_idx = next((i for i, h in enumerate(headers) if "Points" in h), 3) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue pos = cols[pos_idx].text.strip() if pos_idx < len(cols) else "N/A" name = cols[name_idx].text.strip() if name_idx < len(cols) else "N/A" team = cols[team_idx].text.strip() if team_idx < len(cols) and team_idx < len(cols) else "N/A" points = cols[points_idx].text.strip() if points_idx < len(cols) and points_idx < len(cols) else "N/A" info += f"{pos}. {name} ({team}): {points} pts\n" elif rank_type.lower() == "teams": pos_idx = next((i for i, h in enumerate(headers) if "Rank" in h or "Pos" in h), 0) team_idx = next((i for i, h in enumerate(headers) if "Team" in h), 1) points_idx = next((i for i, h in enumerate(headers) if "Points" in h), 2) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue pos = cols[pos_idx].text.strip() if pos_idx < len(cols) else "N/A" team = cols[team_idx].text.strip() if team_idx < len(cols) else "N/A" points = cols[points_idx].text.strip() if points_idx < len(cols) and points_idx < len(cols) else "N/A" info += f"{pos}. {team}: {points} pts\n" elif rank_type.lower() == "nations": pos_idx = next((i for i, h in enumerate(headers) if "Rank" in h or "Pos" in h), 0) nation_idx = next((i for i, h in enumerate(headers) if "Nation" in h or "Country" in h), 1) points_idx = next((i for i, h in enumerate(headers) if "Points" in h), 2) # Skip header row for row in rows[1:]: cols = row.find_all('td') if len(cols) < 3: # Ensure it's a data row continue pos = cols[pos_idx].text.strip() if pos_idx < len(cols) else "N/A" nation = cols[nation_idx].text.strip() if nation_idx < len(cols) else "N/A" points = cols[points_idx].text.strip() if points_idx < len(cols) and points_idx < len(cols) else "N/A" info += f"{pos}. {nation}: {points} pts\n" # Include pagination info if available pagination = soup.find('div', class_='pagination') if pagination: info += "\n" # Find the last page number if available last_page_link = pagination.find_all('a')[-1] if pagination.find_all('a') else None if last_page_link and last_page_link.text.strip().isdigit(): total_pages = int(last_page_link.text.strip()) info += f"Page {page_num} of {total_pages}\n" if info.count('\n') <= 2: # Only contains title and maybe pagination info return f"No rankings data found for the specified parameters." return info except Exception as e: return f"Error retrieving UCI rankings: {str(e)}" if __name__ == "__main__": # Initialize and run the server mcp.run(transport='stdio')
ID: lbkjwm0se5