cfbd-mcp-server

MIT License
8
  • Apple
  • src
  • cfbd_mcp_server
import asyncio import os import sys from importlib.metadata import metadata from dotenv import load_dotenv from typing import Any, TypedDict, Type, cast, Union import httpx from mcp.server.models import InitializationOptions import mcp.types as types from mcp.server import NotificationOptions, Server import mcp.server.stdio from .schema_helpers import create_tool_schema from .cfbd_schema import ( # Request parameter types getGames, getTeamRecords, getGamesTeams, getPlays, getDrives, getPlayStats, getRankings, getMetricsPregameWp, getAdvancedBoxScore, # Response types GamesResponse, TeamRecordResponse, GamesTeamsResponse, PlaysResponse, DrivesResponse, PlayStatsResponse, RankingsResponse, MetricsPregameWpResponse, AdvancedBoxScoreResponse, # Constants VALID_SEASONS, VALID_WEEKS, VALID_SEASON_TYPES, VALID_DIVISIONS ) # Load environment variables load_dotenv() # Initialize server and API configuration server = Server("cfbd") API_KEY = os.getenv("CFB_API_KEY") API_BASE_URL = 'https://api.collegefootballdata.com/' if not API_KEY: raise ValueError("CFB_API_KEY must be set in .env file") # Set up API client session async def get_api_client() -> httpx.AsyncClient: """Create an API client with authentication headers.""" return httpx.AsyncClient( base_url=API_BASE_URL, headers={ "Authorization": f"Bearer {API_KEY}", "Accept": "application/json" }, timeout=30.0 ) @server.list_resources() async def handle_list_resources() -> list[types.Resource]: """List available endpoint schemas as resources.""" return [ types.Resource( uri="schema://games", name="Games endpoint schema", description="Get game information with scores, teams and metadata", mimeType="text/plain" ), types.Resource( uri="schema://records", name="Team records endpoint schema", description="Get team season records", mimeType="text/plain" ), types.Resource( uri="schema://plays", name="Plays endpoint", description="Schema for the /plays endpoint", mimeType="text/plain" ), types.Resource( uri="schema://drives", name="Drives endpoint", description="Schema for the /drives endpoint", mimeType="text/plain" ), types.Resource( uri="schema://play/stats", name="Play/stats endpoint", description="Schema for the /play/stats endpoint", mimeType="text/plain" ), types.Resource( uri="schema://rankings", name="Rankings endpoint", description="Schema for the /rankings endpoint", mimeType="text/plain" ), types.Resource( uri="schema://metrics/wp/pregame", name="Metrics/wp/pregame endpoint", description="Schema for the pregame win probability endpoint", mimeType="text/plain" ), types.Resource( uri="schema://game/box/advanced", name="Advanced box score endpoint", description="Schema for the advanced box score endpoint", mimeType="text/plain" ) ] @server.read_resource() async def handle_read_resource(uri: str) -> str: """Return the schema for the requested endpoint.""" # Map URIs to schema classes schema_map = { "schema://games": { "endpoint": "/games", "parameters": getGames.__annotations__, "response": GamesResponse.__annotations__, "description": "Get game information for specified parameters" }, "schema://records": { "endpoint": "/records", "parameters": getTeamRecords.__annotations__, "response": TeamRecordResponse.__annotations__, "description": "Get team records for specified parameters" }, "schema://plays": { "endpoint": "/plays", "parameters": getPlays.__annotations__, "response": PlaysResponse.__annotations__, "description": "Get play records for specified parameters" }, "schema://drives": { "endpoint": "/drives", "parameters": getDrives.__annotations__, "response": DrivesResponse.__annotations__, "description": "Get drive records for specified parameters" }, "schema://play/stats": { "endpoint": "/play/stats", "parameters": getPlayStats.__annotations__, "response": PlayStatsResponse.__annotations__, "description": "Get play by play records for specified parameters" }, "schema://rankings": { "endpoint": "/rankings", "parameters": getRankings.__annotations__, "response": RankingsResponse.__annotations__, "description": "Get rankings records for specified parameters" }, "schema://metrics/wp/pregame": { "endpoint": "/metrics/wp/pregame", "parameters": getMetricsPregameWp.__annotations__, "response": MetricsPregameWpResponse.__annotations__, "description": "Get pregame win probability records for specified parameters" }, "schema://game/box/advanced": { "endpoint": "/game/box/advanced", "parameters": getAdvancedBoxScore.__annotations__, "response": AdvancedBoxScoreResponse.__annotations__, "description": "Get advanced box score data" } } if uri not in schema_map: raise ValueError(f"Unknown schema URI: {uri}") schema_info = schema_map[uri] # Format the schema information into a readable string schema_text = f""" Endpoint: {schema_info['endpoint']} Description: {schema_info['description']} Input Parameters: {_format_annotations(schema_info['parameters'])} Response Schema: {_format_annotations(schema_info['response'])} Valid Values: - Seasons: {min(VALID_SEASONS)} to {max(VALID_SEASONS)} - WEEKS: {min(VALID_WEEKS)} to {max(VALID_WEEKS)} - Season Types: {', '.join(VALID_SEASON_TYPES)} - Divisions: {', '.join(VALID_DIVISIONS)} """ return schema_text def _format_annotations(annotations: dict) -> str: """Helper function to format type annotations into readable text.""" formatted = [] for name, type_hint in annotations.items(): if str(type_hint).startswith("typing."): # Clean up typing notation type_str = str(type_hint).replace("typing.", "") else: type_str = str(type_hint) formatted.append(f"- {name}: {type_str}") return "\n".join(formatted) def validate_params(params: dict, schema_class: Type[TypedDict]) -> dict: """Validate parameters against a TypedDict schema.""" try: # Get the annotations from the schema class expected_types = schema_class.__annotations__ validated_params = {} # Validate each parameter for key, value in params.items(): if key not in expected_types: raise ValueError(f"Unexpected parameter: {key}") expected_type = expected_types[key] # Special handling for classification parameter if key == "classification" and value is not None: value = value.lower() if value not in VALID_DIVISIONS: raise ValueError(f"Invalid Classification: Must be one of: {', '.join(VALID_DIVISIONS)}") # Handle Optional types if hasattr(expected_type, "__origin__") and expected_type.__origin__ is Union: if type(None) in expected_type.__args__: # Parameter is optional if value is not None: # Validate against the non-None type non_none_type = next(t for t in expected_type.__args__ if t != type(None)) # Handle primitive types if non_none_type in (str, int, float, bool): if not isinstance(value, non_none_type): raise ValueError(f"Parameter {key} must be of type {non_none_type.__name__}") validated_params[key] = value else: validated_params[key] = None else: # Parameter is required if not isinstance(value, expected_type): raise ValueError(f"Parameter {key} must be of type {expected_type.__name__}") validated_params[key] = value # Check for required parameters for param, param_type in expected_types.items(): is_optional = (hasattr(param_type, "__origin__") and param_type.__origin__ is Union and type(None) in param_type.__args__) if not is_optional and param not in params: raise ValueError(f"Missing required parameter: {param}") return validated_params except Exception as e: raise ValueError(f"Parameter validation failed: {str(e)}") @server.list_prompts() async def handle_list_prompts() -> list[types.Prompt]: """List available prompt templates.""" return [ types.Prompt( name="analyze-game", description="Get detailed analysis of a specific game", arguments=[ types.PromptArgument( name="game_id", description="Game ID to analyze", required=True ), types.PromptArgument( name="include_advanced_stats", description="Whether to include advanced statistics (true/false)", required=False ) ] ), types.Prompt( name="analyze-team", description="Analyze a team's performance for a given season", arguments=[ types.PromptArgument( name="team", description="Team name (e.g. Alabama)", required=True ), types.PromptArgument( name="year", description="Season year", required=True ) ] ), types.Prompt( name="analyze-trends", description="Analyze trends over a season", arguments=[ types.PromptArgument( name="year", description="Season year", required=True ), types.PromptArgument( name="metric", description="Metric to analyze (scoring, attendance, upsets)", required=True ) ] ), types.Prompt( name="compare-teams", description="Compare the performance of two teams", arguments=[ types.PromptArgument( name="team1", description="First team name", required=True ), types.PromptArgument( name="team2", description="Second team name", required=True ), types.PromptArgument( name="year", description="Season year", required=True ) ] ), types.Prompt( name="analyze-rivalry", description="Analyze historical rivalry matchups", arguments=[ types.PromptArgument( name="team1", description="First team name", required=True ), types.PromptArgument( name="team2", description="Second team name", required=True ), types.PromptArgument( name="start_year", description="Starting year for analysis", required=False ) ] ) ] @server.get_prompt() async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: """Get a specific prompt template.""" if not arguments: raise ValueError("Arguments are required") if name == "analyze-team": return types.GetPromptResult( messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"I'll help analyze {arguments['team']}'s performance for the {arguments['year']} season by checking the College Football Data API. " f"I'll review their record, key games, rankings and overall statistics." ) ) ] ) elif name == "compare-teams": return types.GetPromptResult( messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Let me check the College Football Data API to compare {arguments['team1']} and {arguments['team2']} " f"in the {arguments['year']} season. I'll look at their head-to-head matchup if they played, " f"their records, common opponents, and statistical performance." ) ) ] ) else: raise ValueError(f"Unknown prompt: {name}") @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """List available tools for querying the API.""" base_description = """Note: When using this tool, please explicitly mention that you are retrieving data from the College Football Data API. You must mention "College Football Data API" in every response. """ return [ types.Tool( name="get-games", description=base_description + """Get college football game data. Required: year Optional: week, season_type, team, conference, category, game_id Example valid queries: - year=2023 - year=2023, team="Alabama" - year=2023, week=1, conference="SEC" """, inputSchema=create_tool_schema(getGames) ), types.Tool( name="get-records", description=base_description + """Get college football team record data. Optional: year, team, conference Example valid queries: - year=2023 - team="Alabama" - conference="SEC" - year=2023, team="Alabama" """, inputSchema=create_tool_schema(getTeamRecords) ), types.Tool( name="get-games-teams", description=base_description + """Get college football team game data. Required: year plus at least one of: week, team or conference. Example valid queries: - year=2023, team="Alabama" - year=2023, week=1 - year=2023, conference="SEC """, inputSchema=create_tool_schema(getGamesTeams) ), types.Tool( name="get-plays", description=base_description + """Get college football play-by-play data. Required: year AND week Optional: season_type, team, offense, defense, conference, offense_conference, defense_conference, play_type, classification Example valid queries: - year=2023, week=1 - year=2023, week=1, team="Alabama" - year=2023, week=1, offense="Alabama", defense="Auburn" """, inputSchema=create_tool_schema(getPlays) ), types.Tool( name="get-drives", description=base_description + """Get college football drive data. Required: year Optional: season_type, week, team, offense, defense, conference, offense_conference, defense_conference, classification Example valid queries: - year=2023 - year=2023, team="Alabama" - year=2023, offense="Alabama", defense="Auburn" """, inputSchema=create_tool_schema(getDrives) ), types.Tool( name="get-play-stats", description=base_description + """Get college football play statistic data. Optional: year, week, team, game_id, athlete_id, stat_type_id, season_type, conference At least one parameter is required Example valid queries: - year=2023 - game_id=401403910 - team="Alabama", year=2023 """, inputSchema=create_tool_schema(getPlayStats) ), types.Tool( name="get-rankings", description=base_description + """Get college football rankings data. Required: year Optional: week, season_type Example valid queries: - year=2023 - year=2023, week=1 - year=2023, season_type="regular" """, inputSchema=create_tool_schema(getRankings) ), types.Tool( name="get-pregame-win-probability", description=base_description + """Get college football pregame win probability data. Optional: year, week, team, season_type At least one parameter is required Example valid queries: - year=2023 - team="Alabama" - year=2023, week=1 """, inputSchema=create_tool_schema(getMetricsPregameWp) ), types.Tool( name="get-advanced-box-score", description=base_description + """Get advanced box score data for college football games. Required: gameId Example valid queries: - gameId=401403910 """, inputSchema=create_tool_schema(getAdvancedBoxScore) ) ] @server.call_tool() async def handle_call_tool( name: str, arguments: dict[str, Any] | None ) -> list[types.TextContent]: """Handle tool execution requests.""" if not arguments: raise ValueError("Arguments are required") # Map tool names to their parameter schemas schema_map = { "get-games": getGames, "get-records": getTeamRecords, "get-games-teams": getGamesTeams, "get-plays": getPlays, "get-drives": getDrives, "get-play-stats": getPlayStats, "get-rankings": getRankings, "get-pregame-win-probability": getMetricsPregameWp, "get-advanced-box-score": getAdvancedBoxScore } if name not in schema_map: raise ValueError(f"Unknown tool: {name}") # Validate parameters against schema try: validated_params = validate_params(arguments, schema_map[name]) except ValueError as e: return [types.TextContent( type="text", text=f"Validation error: {str(e)}" )] endpoint_map = { "get-games": "/games", "get-records": "/records", "get-games-teams": "/games/teams", "get-plays": "/plays", "get-drives": "/drives", "get-play-stats": "/play/stats", "get-rankings": "/rankings", "get-pregame-win-probability": "/metrics/wp/pregame", "get-advanced-box-score": "/game/box/advanced" } async with await get_api_client() as client: try: response = await client.get(endpoint_map[name], params=arguments) response.raise_for_status() data = response.json() return [types.TextContent( type="text", text=str(data) )] except httpx.HTTPStatusError as e: if e.response.status_code == 401: return [types.TextContent( type="text", text="401: API authentication failed. Please check your API key." )] elif e.response.status_code == 403: return [types.TextContent( type="text", text="403: API access forbidden. Please check your permission." )] elif e.response.status_code == 429: return [types.TextContent( type="text", text="429: Rate limit exceeded. Please try again later." )] else: return [types.TextContent( type="text", text=f"API Error: {e}" )] except httpx.RequestError as e: return [types.TextContent( type="text", text=f"Network error: {str(e)}" )] async def main() -> None: """Run the server.""" try: # Get metadata from project.toml pkg_metadata = metadata("cfbd-mcp-server") server_name = pkg_metadata["Name"] server_version = pkg_metadata["Version"] except Exception as e: # Fallback values in case metadata can't be read print(f"Warning: Could not read package metadata: {e}", file=sys.stderr) server_name = "cfbd-mcp-server" server_version = "0.4.0" # Add this line for startup confirmation print("CFB Data MCP Server starting...", file=sys.stderr) async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): print("Server initialized and ready for connections", file=sys.stderr) await server.run( read_stream, write_stream, InitializationOptions( server_name=server_name, server_version=server_version, capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, ), ), ) if __name__ == "__main__": asyncio.run(main())