I built a college football data MCP server that connects Claude to CollegeFootballData.com, a free API with deep historical stats, advanced metrics, recruiting data, and play-by-play going back decades. Its data goes beyond what frontier AI models are trained on. Getting it working was straightforward — there’s a gofastmcp.com tutorial for that. Getting Claude to use it well required understanding something that’s easy to overlook: the key interface between an LLM and a tool is the docstring.


What MCP actually is

Model Context Protocol is an open standard for connecting LLMs to external tools and data sources. The short version: you write a server that exposes functions as tools, and any MCP-compatible client — Claude Desktop, Cursor, or your own application — can discover and call those tools automatically.

Before MCP, connecting Claude to a database meant writing custom integration code tightly coupled to Anthropic’s API. Connecting GPT to the same database meant writing another set of custom integration code. MCP eliminates that duplication by defining a protocol both sides agree on.

An MCP server can expose three primitives:

  • Tools — functions the LLM calls to take action or retrieve data
  • Resources — readable content the LLM can load into context
  • Prompts — reusable templates for complex workflows

This project is entirely about tools.


The setup

Stack: Python 3.12, FastMCP, httpx, python-dotenv, uv.


Project structure

cfb-mcp-server/
├── server.py        # MCP server — tool definitions
├── cfbd_client.py   # CFBD REST API wrapper
├── cache.py         # In-memory TTL cache
├── .env             # CFBD_API_KEY
└── pyproject.toml

The separation between cfbd_client.py and server.py is intentional. cfbd_client.py has no knowledge of MCP; it just makes HTTP calls and returns data. server.py has no knowledge of CFBD internals; it just exposes tools. Each layer can be tested and reasoned about independently.


The API wrapper

# cfbd_client.py
import httpx
import os
from dotenv import load_dotenv
from cache import get as cache_get, set as cache_set

load_dotenv()

BASE_URL = "https://api.collegefootballdata.com"

def _headers() -> dict:
    return {"Authorization": f"Bearer {os.environ['CFBD_API_KEY']}"}

def get_game_results(team: str, year: int) -> list[dict]:
    key = f"games:{team}:{year}"
    if cached := cache_get(key):
        return cached
    r = httpx.get(
        f"{BASE_URL}/games",
        headers=_headers(),
        params={"year": year, "team": team, "classification": "fbs"}
    )
    r.raise_for_status()
    result = r.json()
    cache_set(key, result)
    return result

def get_advanced_team_stats(team: str, year: int) -> list[dict]:
    key = f"advanced:{team}:{year}"
    if cached := cache_get(key):
        return cached
    r = httpx.get(
        f"{BASE_URL}/stats/season/advanced",
        headers=_headers(),
        params={"year": year, "team": team}
    )
    r.raise_for_status()
    result = r.json()
    cache_set(key, result)
    return result

def get_conference_stats(conference: str, year: int) -> list[dict]:
    key = f"conference:{conference}:{year}"
    if cached := cache_get(key):
        return cached
    r = httpx.get(
        f"{BASE_URL}/stats/season/advanced",
        headers=_headers(),
        params={"year": year, "conference": conference}
    )
    r.raise_for_status()
    result = r.json()
    cache_set(key, result)
    return result

The cache is worth a note. The free CFBD tier is 1,000 calls per month. A single Claude session asking several questions about the same team can make a dozen API calls to the same endpoints. A simple TTL cache eliminates that:

# cache.py
import time

_cache: dict = {}
TTL = 3600

def get(key: str):
    entry = _cache.get(key)
    if entry and time.time() - entry["ts"] < TTL:
        return entry["value"]
    return None

def set(key: str, value):
    _cache[key] = {"value": value, "ts": time.time()}

The part that actually matters: docstrings

Here’s where most MCP tutorials stop being useful.

FastMCP makes the mechanical part trivial: decorate a function, it becomes a tool. The hard part isn’t the decoration. It’s what you put in the docstring.

Claude doesn’t see your code. It sees your function signature and your docstring. That’s the entire API contract. When you ask Claude a question, it reads the available tool descriptions and decides which ones to call, in what order, and with what arguments. Entirely based on that text.

Consider three versions of the same tool to retrieve a team’s advanced stats by year:

Version 1: No docstring

@mcp.tool()
def advanced_team_stats(team: str, year: int) -> list[dict]:
    return get_advanced_team_stats(team, year)

With no docstring, Claude has only the function name and parameter names to work with. It might call this tool when asked about a team, but it has no basis for knowing what the data means, when this tool is more appropriate than another, or how to interpret the results it gets back.

Version 2: Minimal docstring

@mcp.tool()
def advanced_team_stats(team: str, year: int) -> list[dict]:
    """Get advanced stats for a team."""
    return get_advanced_team_stats(team, year)

This is marginally better. Claude now knows the function returns advanced stats. But “advanced stats” is vague — it doesn’t tell Claude what EPA is, whether higher or lower values are better, or when to prefer this tool over team_season_stats.

Version 3: Descriptive docstring with usage guidance

@mcp.tool()
def advanced_team_stats(team: str, year: int) -> list[dict]:
    """
    Get advanced efficiency stats for a team: EPA (Expected Points Added) per
    play, success rate, and explosiveness for both offense and defense.

    EPA is the best single metric for team quality — positive offensive EPA
    and negative defensive EPA indicate an efficient team. Use this when the
    user asks about team quality, efficiency, or wants to compare teams beyond
    win-loss record.

    Pair with team_game_results to give context for why a team won or lost
    specific games. A team can have strong EPA and still lose games — context
    matters.
    """
    return get_advanced_team_stats(team, year)

The third version does three things the others don’t:

  1. It defines domain vocabulary. Claude now knows what EPA means and which direction is better. It can explain this to the user rather than just reporting raw numbers.

  2. It specifies when to use this tool vs others. Claude can now make an informed choice between advanced_team_stats and team_season_stats based on what the user is actually asking.

  3. It tells Claude how to combine tools. “Pair with team_game_results” is an instruction. Claude will now call both tools when answering a question about team performance, giving the user both the efficiency context and the actual results, without being explicitly asked to.

That last point is where the behavior change becomes visible. With a weak docstring, Claude calls one tool and returns what it gets. With a strong docstring, Claude calls multiple tools, synthesizes across them, and produces an answer that reads like analysis rather than a data dump.

In an agentic system, prompt engineering doesn’t just happen in your system prompt. It happens in every docstring you write.


The conference tool: an architectural point

One tool design decision worth calling out explicitly:

@mcp.tool()
def conference_advanced_stats(conference: str, year: int) -> list[dict]:
    """
    Get advanced efficiency stats for all teams in a conference in a given
    year. Returns EPA, success rate, and explosiveness for every team.

    Use this when the user asks which team in a conference led a particular
    stat — avoids calling advanced_team_stats once per team. Valid conference
    abbreviations: SEC, ACC, B1G, B12, PAC, AAC.
    """
    return get_conference_stats(conference, year)

Without this tool, answering “which SEC team had the best defensive EPA in 2024” would require Claude to call advanced_team_stats 16 times — once per SEC team. That’s 16 API calls, significant latency, and 16 of my 1,000 monthly free API calls burned on a single question.

The conference tool collapses that to one call. The docstring tells Claude explicitly when to prefer it over the per-team tool. This is a case where the right tool design isn’t just about correctness — it’s about making the system economical.


The full server

# server.py
from mcp.server.fastmcp import FastMCP
from cfbd_client import (
    get_game_results,
    get_advanced_team_stats,
    get_team_season_stats,
    get_player_season_stats,
    get_rankings,
    get_conference_stats,
)

mcp = FastMCP("CFB Stats")


@mcp.tool()
def team_game_results(team: str, year: int) -> list[dict]:
    """
    Get the full schedule and results for a college football team in a given
    year. Returns each game with date, opponent, score, and home/away status.
    Use when the user asks about a team's record, schedule, or specific game
    outcomes. Pair with advanced_team_stats to explain performance beyond the
    score.
    """
    return get_game_results(team, year)


@mcp.tool()
def team_season_stats(team: str, year: int) -> list[dict]:
    """
    Get aggregated season statistics for a college football team. Includes
    standard box score stats: total yards, points, turnovers, third down
    conversions. Use for general volume stats. For efficiency and quality
    metrics, prefer advanced_team_stats.
    """
    return get_team_season_stats(team, year)


@mcp.tool()
def player_stats(team: str, year: int, category: str) -> list[dict]:
    """
    Get player season stats for a team by category.
    Valid categories: passing, rushing, receiving, defensive, kicking, punting.
    Use when the user asks about individual players or who led a team in a
    specific stat.
    """
    return get_player_season_stats(team, year, category)


@mcp.tool()
def ap_rankings(year: int, week: int, season_type: str = "regular") -> list[dict]:
    """
    Get AP Top 25 college football rankings for a specific week and year.
    Season type is 'regular' or 'postseason'. Use when the user asks about
    rankings, polls, or whether a team was ranked at a specific point in
    the season.
    """
    return get_rankings(year, week, season_type)


@mcp.tool()
def advanced_team_stats(team: str, year: int) -> list[dict]:
    """
    Get advanced efficiency stats for a team: EPA (Expected Points Added) per
    play, success rate, and explosiveness for both offense and defense.

    EPA is the best single metric for team quality — positive offensive EPA
    and negative defensive EPA indicate an efficient team. Use when the user
    asks about team quality, efficiency, or wants to compare teams beyond
    win-loss record.

    Pair with team_game_results to explain why a team won or lost specific
    games. A team can have strong EPA and still lose — context matters.
    """
    return get_advanced_team_stats(team, year)


@mcp.tool()
def conference_advanced_stats(conference: str, year: int) -> list[dict]:
    """
    Get advanced efficiency stats for all teams in a conference in a given
    year. Returns EPA, success rate, and explosiveness for every team.

    Use when the user asks which team in a conference led a stat category —
    avoids calling advanced_team_stats once per team. Valid conference
    abbreviations: SEC, ACC, B1G, B12, PAC, AAC.
    """
    return get_conference_stats(conference, year)


if __name__ == "__main__":
    mcp.run()

What I’d add next

Season trend tool — accept a team and year range, return advanced stats for each year. A single call to answer “has Missouri’s defense improved over the last four seasons.”

Team name normalization — CFBD requires exact team names. A simple alias dictionary (“Mizzou” → “Missouri”, “Bama” → “Alabama”) makes the server more forgiving in natural conversation without touching the API layer.

Recruiting data — CFBD has recruiting rankings, individual recruit ratings, and commit histories going back years. This opens a completely different category of questions about roster construction and program trajectory that pure stats can’t answer.

Head-to-head history — the /games endpoint accepts both home and away team filters. A dedicated tool for rivalry records is a two-line addition that makes a disproportionate difference for certain questions.


The broader point

MCP is compelling not because it makes tools easy to write. It’s compelling because it makes tools composable. A tool you write today for Claude Desktop works in Cursor tomorrow and in your own agent application next month, with no changes to the server.

But composability only delivers value if Claude knows how to use the tools you’ve written. That knowledge lives in your docstrings. The mechanical part of building an MCP server is trivial. The design work — deciding what tools to expose, how to describe them, and how to guide Claude toward using them together — is where the challenge is.


Source code

Full project: github.com/tylerwellss/cfb-mcp-server

CFBD API docs: apinext.collegefootballdata.com

FastMCP docs: gofastmcp.com