-
Notifications
You must be signed in to change notification settings - Fork 0
MCP Server Guide
Complete guide to the BetTrack MCP Server - architecture, tools, and development.
- Architecture Overview
- Server Components
- Tool Development
- Configuration
- Formatters
- Testing
- Building & Packaging
The BetTrack MCP Server uses the FastMCP framework to provide sports data tools to Claude Desktop via stdio transport.
- Dual-API Integration: Combines The Odds API (betting data) with ESPN API (stats/schedules)
- Natural Language Search: Fuzzy team name matching for intuitive queries
- Rich Formatting: ASCII cards, Markdown tables, and visual scoreboards
- Async/Await Pattern: All API calls are asynchronous for performance
- Round-Robin API Keys: Supports multiple API keys to distribute quota
FastMCP Framework
├── aiohttp - Async HTTP client
├── Python 3.11+ - Type hints and async features
├── stdio transport - Claude Desktop communication
└── MCPB packaging - Distribution format
Main server entry point with all tool definitions.
Structure:
from fastmcp import FastMCP
import asyncio
# Initialize MCP server
mcp = FastMCP("Sports Data MCP")
# API Handlers
odds_handler = OddsAPIHandler(api_key, bookmakers_filter, bookmakers_limit)
espn_handler = ESPNAPIHandler()
# Tool definitions
@mcp.tool()
async def get_odds(sport: str, regions: str = "us", markets: str = "h2h"):
"""Get betting odds for a sport"""
# Implementation
# Server startup
if __name__ == "__main__":
mcp.run()Total Tools: 30+ decorated with @mcp.tool()
Handles all interactions with The Odds API.
Key Features:
- Round-robin API key rotation
- Usage tracking (logs
x-requests-remaining) - Bookmaker filtering
- Session management
Example:
class OddsAPIHandler:
def __init__(self, api_key: Union[str, List[str]],
bookmakers_filter: List[str],
bookmakers_limit: int):
self.api_keys = [api_key] if isinstance(api_key, str) else api_key
self.current_key_index = 0
async def _make_request(self, endpoint: str, params: Dict) -> Dict:
# Round-robin key selection
api_key = self.api_keys[self.current_key_index]
self.current_key_index = (self.current_key_index + 1) % len(self.api_keys)
# Make requestHandles all ESPN API calls (no authentication required).
Endpoints:
- Site API:
https://site.api.espn.com(news, schedule) - Core API:
https://sports.core.api.espn.com(stats, standings) - CDN:
https://cdn.espn.com(team logos)
Output formatting utilities (574 lines).
Functions:
-
format_matchup_card()- ASCII box-drawing cards (66 char width) -
format_scoreboard_table()- Markdown tables with emoji indicators -
format_detailed_scoreboard()- Quarter-by-quarter breakdowns -
format_standings_table()- Conference/division standings -
format_odds_comparison()- Side-by-side bookmaker odds
Hardcoded team dictionaries for NFL, NBA, NHL (216 lines).
Functions:
-
find_team_id(team_name: str, sport: str)- Fuzzy match team to ESPN ID -
get_team_logo_url(team_name: str, sport: str, dark_mode: bool)- Generate CDN URLs
All tools use the @mcp.tool() decorator:
@mcp.tool()
async def search_odds(
query: str,
sport: Optional[str] = None,
markets: str = "h2h"
) -> dict:
"""
Search for odds by team name or matchup (natural language).
Args:
query: Team name or matchup (e.g., "Lakers vs Celtics")
sport: Sport key (optional, searches all if omitted)
markets: Comma-separated markets (h2h,spreads,totals)
Returns:
{"success": bool, "data": [...], "error": str}
"""
try:
# Implementation
result = await odds_handler.search_odds(query, sport, markets)
return {"success": True, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}Always return a dict with this structure:
{
"success": True, # or False
"data": { ... }, # Only on success
"error": "message" # Only on failure
}Tools use fuzzy matching for team names:
from difflib import get_close_matches
def find_team_id(team_name: str, sport: str) -> Optional[str]:
"""Fuzzy match team name to ESPN ID"""
team_dict = {
"nba": NBA_TEAMS,
"nfl": NFL_TEAMS,
"nhl": NHL_TEAMS
}.get(sport)
if not team_dict:
return None
# Try exact match first
if team_name in team_dict:
return team_dict[team_name]["id"]
# Fuzzy match
matches = get_close_matches(team_name, team_dict.keys(), n=1, cutoff=0.6)
return team_dict[matches[0]]["id"] if matches else NoneLocation: .env file in config directory
-
Windows:
%APPDATA%\Claude\sports-mcp-config\.env -
macOS:
~/Library/Application Support/Claude/sports-mcp-config/.env -
Linux:
~/.config/Claude/sports-mcp-config/.env
Variables:
# Required for betting odds
ODDS_API_KEY=your_key_here
# Optional: Filter bookmakers (comma-separated)
BOOKMAKERS_FILTER=draftkings,fanduel,betmgm
# Optional: Limit number of bookmakers returned
BOOKMAKERS_LIMIT=5
# Optional: Logging level
LOG_LEVEL=INFOSet multiple keys separated by commas:
ODDS_API_KEY=key1_here,key2_here,key3_hereServer logs: "Easter egg activated! Using 3 API keys in round-robin mode"
Location: %APPDATA%\Claude\config\claude_desktop_config.json
{
"mcpServers": {
"sports-data": {
"command": "python",
"args": ["C:/path/to/sports_mcp_server.py"],
"env": {
"ODDS_API_KEY": "your_key_here",
"BOOKMAKERS_FILTER": "draftkings,fanduel",
"BOOKMAKERS_LIMIT": "3"
}
}
}
}def format_matchup_card(game: Dict) -> str:
"""
Returns ASCII box-drawing card with centered text.
Width: 66 characters for consistent display.
"""
# Example output:
# ╔════════════════════════════════════════════════════════════════╗
# ║ Lakers @ Celtics ║
# ║ Wed, Jan 15 • 7:30 PM ET ║
# ╟────────────────────────────────────────────────────────────────╢
# ║ Score: Lakers 98 - Celtics 105 (Final) ║
# ╚════════════════════════════════════════════════════════════════╝def format_scoreboard_table(games: List[Dict]) -> str:
"""
Returns Markdown table with emoji status indicators.
"""
# Example output:
# | Status | Away | Home | Time |
# |--------|------|------|------|
# | 🔴 Live | Lakers 98 | Celtics 105 | Q4 2:34 |
# | ⏰ Scheduled | Heat | Bulls | 7:30 PM ET |
# | ✅ Final | Warriors 110 | Nets 102 | Final |def shorten_team_name(name: str, max_length: int) -> str:
"""
Preserve last word (team nickname) when shortening.
Examples:
- "Los Angeles Lakers" → "LA Lakers" (preserve "Lakers")
- "Golden State Warriors" → "GS Warriors" (preserve "Warriors")
"""No automated tests yet. Manual testing via Claude Desktop.
mcp/tests/
├── test_odds_api_handler.py # Mock Odds API responses
├── test_espn_api_handler.py # Mock ESPN API responses
├── test_formatters.py # Test output formatting
├── test_team_reference.py # Test fuzzy matching
├── test_mcp_tools.py # Integration tests
└── conftest.py # Shared fixtures# Install dependencies
pip install pytest pytest-asyncio pytest-mock aioresponses
# Run tests
pytest tests/ -v
# With coverage
pytest tests/ --cov=sports_api --cov-report=html# test_formatters.py
import pytest
from sports_api.formatter import format_matchup_card
def test_matchup_card_width():
"""Ensure all cards are exactly 66 characters wide"""
game = {
"home_team": "Los Angeles Lakers",
"away_team": "Boston Celtics",
"commence_time": "2026-01-15T19:30:00Z"
}
card = format_matchup_card(game)
lines = card.split("\n")
assert all(len(line) == 66 for line in lines)Location: scripts/build.ps1
# Navigate to scripts directory
cd scripts
# Build MCP server package
.\build.ps1 -VersionBump patch
# Beta build (git hash versioning)
.\build.ps1 -Beta
# Full release (version bump + GitHub release)
.\build.ps1 -VersionBump minor -Release-
-VersionBump <patch|minor|major>- Bump semantic version -
-Beta- Create beta version with git hash -
-Release- Create GitHub release and push tag -
-Clean- Remove build artifacts
MCPB packages saved to mcp/releases/:
sports-data-mcp-v0.1.13.mcpb
sports-data-mcp-v0.1.14-beta.928845c.mcpb
MCPB is a ZIP archive with .mcpb extension containing:
sports-data-mcp.mcpb (ZIP)
├── sports_mcp_server.py
├── manifest.json
├── requirements.txt
├── sports_api/
│ ├── odds_api_handler.py
│ ├── espn_api_handler.py
│ ├── formatter.py
│ └── team_reference.py
└── .env.example
manifest.json:
{
"name": "sports-data-mcp",
"version": "0.1.13",
"description": "Sports betting odds and game data MCP server",
"author": "Your Name",
"license": "MIT",
"entry_point": "sports_mcp_server.py",
"python_version": ">=3.11",
"dependencies": [
"fastmcp>=0.1.0",
"aiohttp>=3.9.0"
]
}class OddsAPIHandler:
def __init__(self, api_key, bookmakers_filter, bookmakers_limit):
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Lazy session creation, reuses existing"""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
"""Clean up session"""
if self._session and not self._session.closed:
await self._session.close()async def _make_request(self, endpoint: str, params: Dict) -> Dict:
"""Make API request with error handling"""
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
return await response.json()
elif response.status == 429:
raise Exception("API rate limit exceeded")
else:
raise Exception(f"API error: {response.status}")
except aiohttp.ClientError as e:
raise Exception(f"Network error: {str(e)}")Reduce API response size by filtering to preferred sportsbooks:
def filter_bookmakers(odds: List[Dict], bookmakers: List[str], limit: int) -> List[Dict]:
"""Filter and limit bookmakers"""
filtered = [b for b in odds if b["key"] in bookmakers] if bookmakers else odds
return filtered[:limit]Generate ESPN CDN URLs for team logos:
def get_team_logo_url(team_name: str, sport: str, dark_mode: bool = False) -> str:
"""
Returns: https://a.espncdn.com/i/teamlogos/{sport}/500/{team_id}.png
Dark mode available for some sports (adds /dark/ to path)
"""
team_id = find_team_id(team_name, sport)
if not team_id:
return None
base = "https://a.espncdn.com/i/teamlogos"
dark = "/dark" if dark_mode else ""
return f"{base}/{sport}{dark}/500/{team_id}.png"- Use async/await: All API calls should be async
- Reuse sessions: Don't create new ClientSession for each request
-
Limit bookmakers: Use
BOOKMAKERS_LIMITto reduce response size - Cache lookups: Team ID lookups can be cached (static data)
-
Batch requests: Use
asyncio.gather()for parallel API calls
Example parallel requests:
# Fetch multiple sports odds simultaneously
results = await asyncio.gather(
odds_handler.get_odds("basketball_nba"),
odds_handler.get_odds("americanfootball_nfl"),
odds_handler.get_odds("icehockey_nhl")
)Issue: Tools not showing in Claude Desktop
- Solution: Restart Claude Desktop after installation
- Check: Verify manifest.json has correct entry_point
Issue: API key not working
- Solution: Check .env file location (config directory, not install directory)
- Check: Verify no extra spaces in ODDS_API_KEY value
Issue: Rate limit errors
- Solution: Use round-robin API keys or upgrade to paid tier
-
Check: Monitor
x-requests-remainingin logs
Issue: Team not found errors
- Solution: Check team name spelling, try abbreviation (e.g., "LAL" instead of "Lakers")
- Check: Verify sport is supported in team_reference.py