Ask an AI assistant "who's most likely to win the 2026 World Cup?" and it will happily invent a number. It has no live data and no simulation, just a confident-sounding guess.
I wanted to fix that for sports. So I built SportIQ: a Model Context Protocol server that gives any MCP-speaking assistant (Claude, Cursor, ChatGPT) 44 real sports tools across the FIFA World Cup 2026, Formula 1, and IPL cricket. When you ask that World Cup question now, the model doesn't guess. It calls football_simulate_bracket, which runs 10,000 Monte Carlo simulations of the actual 48-team tournament and returns structured probabilities the model then explains in plain English.
I designed it, built it, and published it to PyPI in 10 days. Here's how it works.
First, what is MCP?
The Model Context Protocol is an open standard that lets an AI assistant call external tools through one uniform interface. Instead of hallucinating an answer, the model calls a function that runs real code and returns structured JSON.
SportIQ uses FastMCP, which turns each Python function's type hints and docstring into the tool schema the model sees. The flow for a single question:
- You ask Claude "who's most likely to win the World Cup?"
- Claude reads its available tool schemas, picks
football_simulate_bracket, and fills in the arguments. - The server runs the simulation behind a data-fallback chain and a cache, then returns a
{data, meta}envelope, including whether the data was stale. - Claude reads
is_stale: trueand can honestly say "as of 4 minutes ago…".
Install one server, and every MCP client you own gains 44 sports tools.
The architecture
The stack is deliberately lean: Python 3.11+, the mcp SDK with FastMCP, httpx + tenacity for resilient async HTTP, pydantic v2 for schemas, Redis for caching (with an automatic diskcache fallback), and PuLP, scipy, and numpy for the intelligence layer. It ships via uvx, so anyone can run it with zero install.
Tools aren't decorated inline. Each sport module exposes a register_*_tools(mcp) function, and the entry point wires them together:
# server.py
register_football_tools(mcp)
register_f1_tools(mcp)
register_cricket_tools(mcp)
register_cross_sport_tools(mcp)
instrument_tools(mcp) # wraps every tool with telemetry after registration
Every sport follows the same layered shape: adapters/ (data sources), chains.py (fallback logic), models/ (pure business logic, no I/O), and a thin tools.py that just validates input, routes through a chain, and wraps the result in an envelope.
The two patterns that make it reliable
Raw data tools (fixtures, standings, scorecards) are table stakes. The hard part is that free-tier sports APIs are flaky and rate-limited, and an AI tool that fails half the time is useless. Two patterns solve this:
1. Every tool routes through a FallbackChain. No tool ever calls an API directly. The chain resolves in a strict order: fresh cache → walk each data source in order (first success caches and returns) → stale cache (flagged is_stale) → only then raise an error. Behind every paid API sits a keyless fallback (openfootball, the Jolpica/Ergast F1 archive, and bundled static JSON seeds) so the server keeps answering even when the primary source is down.
2. Budgeted rate-limiting. Per-source token buckets (in Redis) respect each provider's free-tier cap: CricAPI's 100 requests/day, API-Football's 100/day, The Odds API's 500/month. The key rule: budget is only consumed after a successful fetch, so failed calls never burn quota. Caching is TTL-tiered by how fast the data changes: a live cricket scorecard caches for 30 seconds, F1 telemetry for 10, fixtures for 6 hours, static seeds forever.
Three tools I'm proud of
Anyone can wrap an API. The three tools below are why SportIQ is an analyst, not a data dump.
1. A constraint-solved fantasy team (cricket_build_dream11_team)
Picking a Dream11 fantasy XI is a real optimisation problem: maximise projected points subject to a pile of rules. I model it as a binary integer linear program solved by CBC via PuLP: exactly 11 players, ≤100 credits, ≤7 from one team, role bounds per position, and a captain/vice-captain multiplier:
prob += lpSum(x) == 11, "squad_size"
prob += lpSum(candidates[i]["credits"] * x[i] for i in range(n)) <= 100, "credit_cap"
for team in teams: # ≤7 per team
prob += lpSum(x[i] for i in members) <= 7, f"team_cap_{team}"
for role, (lo, hi) in role_bounds.items():
prob += lpSum(x[i] for i in members) >= lo
prob += lpSum(x[i] for i in members) <= hi
Infeasible inputs (over budget, no wicketkeeper, fewer than 11 candidates) raise a clean InvalidInputError instead of silently returning garbage.
2. A full-tournament Monte Carlo simulator (football_simulate_bracket)
This simulates the real 48-team, 12-group World Cup 2026 format, not a toy 32-team bracket. Each of 10,000 iterations plays all 12 groups, takes the top two plus the eight best third-placed teams, slots them into the official FIFA knockout tree (with the Annex C best-thirds allocation), and plays to a champion.
The match engine is a Poisson model seeded by Elo ratings:
supremacy = ((elo_home + home_advantage) - elo_away) * 0.004 # ~0.4 goals per 100 Elo
lambda_home = max(0.05, (avg_total_goals + supremacy) / 2.0) # avg_total_goals = 2.6
lambda_away = max(0.05, (avg_total_goals - supremacy) / 2.0)
Scoreline probabilities come from scipy.stats.poisson, and live results condition the simulation (an eliminated team gets probability zero). The output is each team's chance of reaching every round and lifting the trophy. Ten thousand iterations gives stable probabilities to within a couple of percent.
3. An F1 pit-strategy model (f1_predict_pit_strategy)
This one eats real OpenF1 telemetry (lap durations, tyre compounds, tyre life, stint data, weather) and fits a tyre-degradation slope per compound from actual laps. Then it walks the rest of the race lap by lap, triggering a pit stop when cumulative degradation exceeds the pit-lane time loss (the classic break-even) or when a compound runs past its safe window (the performance "cliff"):
projected_loss = slope * remaining # degradation cost of staying out
stop_warranted = projected_loss > pit_loss_s or remaining > safe_window
Rainfall flips the whole plan to intermediates. It outputs predicted stop laps, a compound sequence, and a confidence score scaled by how many real laps it had to fit. Notably, it doesn't predict finishing position: that would need a race-wide interaction model I haven't built, and I'd rather ship a number I can defend than one I can't.
Shipping it to PyPI
The whole thing is published as sportiq-mcp on PyPI. Running it is a single command:
uvx sportiq-mcp
uvx runs the package straight from PyPI with no install step. The magic is a tiny contract: an entry-point script (sportiq-mcp = "sportiq.server:main") plus a main() that calls mcp.run(). Built with hatchling, the published archive aggressively excludes planning docs, tests, and scripts, and ships with signed build attestations.
A couple of packaging lessons landed the hard way. On Apple Silicon, PuLP's bundled CBC solver is x86-only, so the Dream11 tool silently failed until I documented brew install cbc. And fastf1 is a heavy dependency, so it's an optional extra: the base install stays lean, and F1 telemetry features light up only if you want them.
The response
I built SportIQ to be anonymous by design: it answers a tool call and forgets it, with no per-user tracking. But the aggregate infrastructure metrics tell a story I didn't expect this fast. In roughly its first three weeks live, the server has handled close to 20,000 requests and picked up 845 PyPI installs.
The part I care about most isn't the raw volume; it's who is calling it. The client breakdown shows real AI assistants in the mix: Claude and ChatGPT are actively invoking SportIQ's tools, alongside Node and Python MCP clients. That's the whole thesis working end to end: an AI, faced with a sports question, reaching for a real tool instead of guessing.
It's not all clean, and I'd rather be honest about that. The failure rate is real: plenty of tool calls still fail when every upstream source in a fallback chain is down or rate-limited at once. Watching those failures on the dashboard is exactly what's driving the next round of adapter and caching work.
What 10 days taught me
Building this fast forced good instincts:
- Design for failure first. Every external data source will fail eventually. The
FallbackChainand stale-cache-with-a-flag pattern meant a dead API degrades the answer instead of breaking the tool. - Respect other people's budgets. Consuming rate-limit tokens only on success, and never bypassing the cache "just to be fresh", is what keeps a free-tier-powered tool alive for every user at once.
- Guard your cold starts.
scipyandPuLPare lazy-imported inside the functions that need them, so a request only pays for what it actually uses. - Model honestly. My constants are calibrated to be sane, not exact (0.4 goals per 100 Elo, 2.6 average goals a game). Where I couldn't defend a number (like F1 finishing position), I left it out rather than faking it.
SportIQ started as a way to stop AI assistants from bluffing about sports. It turned into the best crash course I've had in resilient systems, optimisation, and shipping real software people can install with one command.
You can try it with uvx sportiq-mcp, or find the source on GitHub. 🚀