Metadata-Version: 2.4
Name: sportsdata-mcp
Version: 0.18.0
Summary: MCP server for sports-data APIs (bookmakers, league data, aggregators). Capability-tag system enables cross-provider composition.
Author: Daniel Tomaro
License-Expression: MIT
License-File: LICENSE
Keywords: betting,mcp,model-context-protocol,odds,sports,sports-data,statistics
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Requires-Dist: click<9,>=8.1
Requires-Dist: fastmcp<4,>=0.4
Requires-Dist: httpx[http2]<1,>=0.27
Requires-Dist: pydantic<3,>=2.5
Requires-Dist: pyyaml<7,>=6.0
Provides-Extra: build
Requires-Dist: cryptography<50,>=42; extra == 'build'
Requires-Dist: pyinstaller>=6; extra == 'build'
Provides-Extra: dev
Requires-Dist: cryptography<50,>=42; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: kalshi-auth
Requires-Dist: cryptography<50,>=42; extra == 'kalshi-auth'
Description-Content-Type: text/markdown

# sportsdata-mcp

[![CI](https://github.com/DanielTomaro13/sportsdata-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielTomaro13/sportsdata-mcp/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/DanielTomaro13/sportsdata-mcp)](https://github.com/DanielTomaro13/sportsdata-mcp/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![sportsdata-mcp MCP server](https://glama.ai/mcp/servers/DanielTomaro13/sportsdata-mcp/badges/score.svg)](https://glama.ai/mcp/servers/DanielTomaro13/sportsdata-mcp)

**Free & open source (MIT).** Live sports data and cross-book betting odds as
MCP tools — ~500 tools across 28 providers, in Claude Desktop, Cursor, or any
MCP client. Zero config: install, run `setup`, and the full catalogue serves.

An [MCP](https://modelcontextprotocol.io) server that exposes sports-data APIs
(bookmakers, league/governing-body feeds, aggregators) as tools, configurable so
you only load the tool groups you need. A capability-tag system makes tools from
different providers interchangeable wherever they answer the same question — so
the model can compare odds across bookies or stats across data sources with one
discovery call.

The catalogue spans bookmakers, league/governing-body feeds, and stats
aggregators, and it keeps growing. New providers are added by dropping a YAML
spec into `src/sportsdata_mcp/specs/` — the engine needs no code changes — so
the exact provider and tool counts move over time. Run `sportsdata-mcp
list-groups` for the live inventory, and three meta-tools (group discovery,
capability lookup, resource listing) are always on regardless of what you
enable.

## Install

**Prebuilt app** (no Python needed): grab the latest
[release](https://github.com/DanielTomaro13/sportsdata-mcp/releases/latest)
(macOS + Windows), unzip, and run `sportsdata-mcp setup` — it writes the config
for Claude Desktop / Cursor for you. The macOS build is unsigned for now:
right-click → Open the first time.

From source:

```bash
git clone https://github.com/DanielTomaro13/sportsdata-mcp.git
cd sportsdata-mcp
pip install -e .                # add ".[dev]" for the test + lint toolchain
```

Or run it directly with `uvx`:

```bash
uvx --from git+https://github.com/DanielTomaro13/sportsdata-mcp.git sportsdata-mcp serve
```

## Quickstart

```bash
sportsdata-mcp version          # print version info
sportsdata-mcp list-groups      # see every available tool group
sportsdata-mcp lint             # validate the packaged specs
sportsdata-mcp doctor           # probe enabled groups for reachability + auth
sportsdata-mcp serve            # start the MCP stdio server (default command)
sportsdata-mcp update-specs     # OTA-refresh provider specs (signed bundle); --clear reverts
```

Provider endpoints drift (e.g. Entain rotates its GraphQL persisted-query hashes).
`update-specs` fetches a **signed** spec bundle and applies it into an overlay under
`~/.sportsdata/spec-overlay`, which the loader prefers over the packaged copy — so a drift
fix doesn't need a whole new app build. The bundle is Ed25519-verified against a baked key
(a product build refuses an unsigned/forged bundle; anti-rollback refuses a stale replay).
Publish one with `scripts/publish-spec-bundle.py`; point `--url` / `$SPORTSDATA_SPEC_FEED_URL`
at the asset. Restart the server after applying.

Enable tool groups with a config file or the `SPORTSDATA_MCP_GROUPS` env var:

```bash
SPORTSDATA_MCP_GROUPS="afl.public.core,sportsbet.racing,entain.graphql" sportsdata-mcp serve
```

See [`examples/`](./examples) for Claude Desktop / Claude Code config snippets,
a worked cross-bookie [odds-comparison prompt](./examples/comparator-prompt.md),
and an [NBA shot-chart + box-score walkthrough](./examples/nba-prompt.md) that
shows the `nba_stats_call` dispatcher pattern end to end.

## Configuration

Config is resolved in this order (first hit wins):

1. `--config <path>` flag
2. `$SPORTSDATA_MCP_CONFIG`
3. `./sportsdata-mcp.yaml`
4. `~/.config/sportsdata-mcp/config.yaml`
5. built-in defaults

```yaml
# sportsdata-mcp.yaml
enabled_groups:
  - afl.public.core
  - sportsbet.racing
  - entain.graphql

providers:                      # all optional; sensible defaults apply
  sportsbet:
    request_timeout_seconds: 30
    rate_limit_rps: 10          # sustained requests/sec (token bucket)
    max_response_bytes: 0       # 0 = no cap (default); set a positive byte count to guard context

secrets: {}                     # for authenticated providers; prefer env vars in prod
```

A provider whose auth reads `env: SOME_VAR` is satisfied by the real environment
variable first, then by a `secrets: { SOME_VAR: "..." }` entry of the same name
(a local-dev convenience — keep real secrets in the environment in production).

### Environment variables

| Variable | Effect |
| --- | --- |
| `SPORTSDATA_MCP_GROUPS` | Comma-separated group list; overrides `enabled_groups`. |
| `SPORTSDATA_MCP_CONFIG` | Path to a config file (see resolution order above). |
| `SPORTSDATA_MCP_MAX_BYTES` | Global response-size cap in bytes for every provider that doesn't set its own `max_response_bytes`. `0` (the default) means no cap. |
| `SPORTSDATA_LICENSE` | **Dormant** — the product is free; nothing requires a licence. The signed-entitlement machinery remains for anyone self-hosting gated premium feeds (see below). |
| `SPORTSDATA_ENTITLEMENT_URL` / `SPORTSDATA_ENTITLEMENT_PUBKEY` | Only relevant with the dormant entitlement gate above. Normally unset. |

### The (dormant) entitlement gate

This project used to be a paid product. It's free now — **no licence exists or is
needed, and every group serves by default** — but the signed-entitlement machinery
(Ed25519-verified feed grants, offline caching, 15-min revalidation) is kept dormant
rather than deleted: it's tested, harmless when unset, and useful to anyone
self-hosting this server who wants to gate premium feeds for their own users. Set
`SPORTSDATA_LICENSE` + `SPORTSDATA_ENTITLEMENT_URL` against your own issuing service
to activate it; leave them unset (the default) and nothing changes.

**Keyed feeds.** A few providers need an upstream credential you supply yourself —
e.g. `DATAGOLF_KEY` for DataGolf, `X_BEARER_TOKEN` for Twitter/X. Everything else
needs no key at all.

Meta-tools (`list_available_groups`, `list_tools_by_capability`, `list_resources`)
are always registered regardless of what is enabled, so a fresh install can still
guide the model to turn groups on.

**On the response-size cap.** There is **no cap by default** — every tool returns
whatever the upstream API sends. If you want to guard the model's context window you
can opt in to a cap: precedence is `providers.<id>.max_response_bytes` >
`SPORTSDATA_MCP_MAX_BYTES` > the default (`0`, unlimited). Be aware that very large
payloads (e.g. Sportsbet's full `*_event_markets` firehose, ~2 MB) won't fit in
Claude's ~200 K-token context regardless — for those, prefer a narrower tool such as
`sportsbet_sports_card` with `includeTopMarkets: true`.

## Tool groups

Run `sportsdata-mcp list-groups` for live counts and descriptions.

### AFL — `api.afl.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `afl.public.core` | 22 | Competitions, seasons, rounds, fixtures, ladders, match stats |
| `afl.public.broadcasting` | 9 | Broadcast regions, guides, providers |
| `afl.public.content` | 8 | News/articles, videos, photos |
| `afl.premium.cfs` | 1 | CFS premium ops — needs the anonymous `x-media-mis-token` |
| `afl.premium.statspro` | 1 | StatsPro ops — needs the `x-media-mis-token` |
| `afl.premium.keyserver` | 1 | HLS video URL signing |

### Sportsbet — `sportsbet.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `sportsbet.racing` | 15 | Race meetings, racecards, results, futures, SRMs |
| `sportsbet.sports` | 14 | Sport events, markets, prices, SGMs |
| `sportsbet.cross` | 12 | Live status, commentary, ladders, promos, video |
| `sportsbet.results` | 2 | Resulted events by date |
| `sportsbet.graphql` | 1 | Persisted GraphQL gateway (`apigw/sportsbook/graph`) |

### Entain / Ladbrokes — `ladbrokes.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `entain.rest` | 13 | Navigation quick-links and REST surfaces |
| `entain.graphql` | 1 | 127 persisted GraphQL ops (`gql/router`) |
| `entain.cdn` | 1 | Contentful CMS entries (promotions, major-event nav) |

### PointsBet — `pointsbet.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `pointsbet.sports` | 10 | Sports catalogue, competition/event feeds, full event markets, in-play, search |
| `pointsbet.racing` | 11 | Meetings, racecards, results, futures, SRMs, tips, form |
| `pointsbet.content` | 3 | Promotions, promo-code splash, + `pointsbet_content_call` over the static CMS/nav assets |

### TAB — `tab.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `tab.racing` | 9 | Dates, meetings, racecards (fixed + parimutuel), form, next-to-go, jackpots, futures |
| `tab.sports` | 9 | Sports/competitions tree, full match markets + SGM, focused match markets, next-to-go, results, multi-builder |
| `tab.discovery` | 4 | Featured/live recommendations + `tab_cms_call` over the CMS content feeds |

### Unibet — `unibet.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `unibet.racing` | 1 | `unibet_racing_call` — persisted-GraphQL: meetings, race cards, form, futures, specials |
| `unibet.sport` | 3 | `unibet_kambi_call` over the Kambi offering API (groups, events, bet offers, in-play, bet-builder) + live stats + odds ladder |

### BetR — `betr.com.au` (BlueBet platform)

| Group | Tools | Notes |
|---|---:|---|
| `betr.racing` | 8 | Next-to-jump, today's/grouped racecards, race card, form, fluctuations, movers |
| `betr.sport` | 7 | Event types, competition categories, event markets, match detail, popular SGMs |
| `betr.content` | 4 | Promotions + featured racing + popular market links |

### Pinnacle — `pinnacle.com` (sharp odds)

| Group | Tools | Notes |
|---|---:|---|
| `pinnacle.sports` | 13 | Sports/leagues, full + highlighted + live + per-league matchups, carousel, matchup detail, straight + parlay markets (American-odds prices) |
| `pinnacle.reference` | 4 | Enums, market-label dictionary, teaser definitions, API status |

### Betfair Exchange — `betfair.com.au` (exchange odds)

| Group | Tools | Notes |
|---|---:|---|
| `betfair.exchange` | 3 | `bymarket` + `byevent` back/lay price feeds (the sharpest odds) + cash-out availability |
| `betfair.navigation` | 1 | `bynode` catalogue graph (sport → meeting → event → market) |
| `betfair.inplay` | 5 | Live scores, event details, timeline (single + batch), scores+broadcast |

### Dabble — `dabble.com.au` (iOS app backend)

| Group | Tools | Notes |
|---|---:|---|
| `dabble.sport` | 5 | Discover any competition (active list / name lookup / sports), then its fixtures (embedded markets + decimal odds) + the full per-fixture book (400+ markets + Pick'em props) |

The Australian social-betting app's backend, read directly. Reached by **posing
as the iOS app** — the spec bakes the app's `User-Agent` + `x-device-id` +
`x-app-version` so the public feeds return JSON anonymously. **AU-only** and
Cloudflare-fronted (403s from non-AU IPs, like the other AU books). Works for
**any** competition — `dabble_active_competitions` lists the ~269 currently-bettable
ones across all sports. Read-only odds — no bet placement.
Composes with the other books via `sport.event_markets` / `sport.prices`.

### SuperCoach — `supercoach.com.au` (News Corp / Champion Data fantasy)

| Group | Tools | Notes |
|---|---:|---|
| `supercoach.fantasy` | 6 | One uniform surface across **all 7 games** (afl/nrl/epl/nba/nbl/nfl/bbl) × **2 modes** (`classic` + `draft`): competition state, the full per-player feed (price + `ppts1` projection + ownership + matchup; draft adds `predraft_rank`), fixtures (with H2H odds), club + single-player catalogues, leagues |

News Corp / Champion Data's salary-cap fantasy game. Every feed lives under
`/{year}/api/{sport}/classic/v1/…` — pass `sport` (one of the seven) and `year`
(the season key: current calendar year for afl/nrl, currently `2025` for the
others, which run across the new year). **No auth, not geo-blocked** (runs in CI).
The core `supercoach_players` feed is per-round and large (~1–3 MB); use `ppts1`
(the real projection), not `ppts`. Adds the **fantasy / projections** angle via
`stats.fantasy_projections` alongside Data Golf. See
[documentation/SuperCoach.md](documentation/SuperCoach.md).

### NBL — `nbl.com.au` (Australian National Basketball League)

| Group | Tools | Notes |
|---|---:|---|
| `nbl.basketball` | 14 | Seasons, teams, ladder, schedule (scores), players + rosters, per-player season stats + game-log box scores, team stats, season stat leaders (sortable), and news |

The league's own site data API — a Redis-cached proxy (**"rosetta"**) over Genius
Sports stats at `prod.rosetta.nbl.com.au/get/…`. **No token**, but **referer-gated**
(403s without an `nbl.com.au` Origin + Referer — both baked into the spec). Every
response is enveloped `{type, count, source, data:[…]}`. Season-scoped by `year`
(the season start year: 2025 = NBL26, current); stat-leaders takes the season UUID
from `nbl_seasons`. Distinct from the SuperCoach `nbl` fantasy feed — this is the
official box-score source. See [documentation/NBL.md](documentation/NBL.md).

### WTA — `wtatennis.com` (Women's Tennis Association, official)

| Group | Tools | Notes |
|---|---:|---|
| `wta.tennis` | 8 | Official WTA API: singles/doubles rankings, player catalogue + profiles + match history, tournament calendar + per-edition results + entry lists (seeds) |

The WTA's **official** data API (`api.wtatennis.com/tennis/…`) — public Spring REST,
**no auth/key, no geo-block**, runs in CI. Rankings need `type`+`metric`
(rankSingles+singles or rankDoubles+doubles); tournaments are keyed by
`tournamentGroup.id` + `year` (Australian Open = group 901). Fills the tennis gap on
the stats side, composing with the bookmakers' live tennis markets. See
[documentation/WTA.md](documentation/WTA.md). (ATP has no equivalent open API —
atptour.com is Cloudflare bot-protected — so it isn't modelled.)

### Racing and Sports — `racingandsports.com.au`

| Group | Tools | Notes |
|---|---:|---|
| `racingandsports.racing` | 3 | Today's race meetings (all codes, verified) + sports match list + per-race odds (token) |

### Data Golf — `datagolf.com` (needs a key)

| Group | Tools | Notes |
|---|---:|---|
| `datagolf.general` | 3 | Player list, tour schedule, current event field |
| `datagolf.predictions` | 11 | DG rankings, pre-tournament (+ archive) + in-play model probabilities, skill + approach-skill ratings, player/live SG decompositions, live strokes-gained, live hole stats, DFS projections |
| `datagolf.betting` | 3 | Outright + matchup + all-pairings odds across ~13 books (incl. model line) |
| `datagolf.historical` | 9 | Archived raw round data, event-level results (finishes/earnings/points), historical bookmaker odds (outrights + matchups) and DFS results |

Needs a Data Golf API key in the `DATAGOLF_KEY` env var (a personal subscription
key — sourced via the `static_query` auth scheme, never stored in the repo).

### FanDuel — `fanduel.com` (US)

| Group | Tools | Notes |
|---|---:|---|
| `fanduel.racing` | 4 | `fanduel_racing_call` (full-query GraphQL: featured/today races + odds, single-race card, tracks, pools, talent picks) + messages/quick-links/promotions |
| `fanduel.sportsbook` | 2 | `fanduel_sb_call` (REST: event pages + markets, in-play, promos, configs via the `_ak` key) + live scores |

### NRL — `mc.championdata.com`

| Group | Tools | Notes |
|---|---:|---|
| `nrl.public.core` | 4 | Champion Data match centre: competitions, fixture, per-match player stats, app settings |

Plus the `nrl://stats/definitions` resource (dictionary of every NRL stat code).

### NBA — `cdn.nba.com` + `stats.nba.com`

| Group | Tools | Notes |
|---|---:|---|
| `nba.public.cdn` | 5 | Open CDN JSON: today's scoreboard, full schedule, live box score + play-by-play, odds |
| `nba.stats` | 2 | `nba_daily_lineups` + `nba_stats_call`, the dispatcher over the 138-endpoint `/stats/` API |

`nba_stats_call` fronts the whole stats.nba.com `/stats/` analytics surface (player/team
dashboards, box scores v2+v3, shot charts, play-by-play, leaders, standings, draft, hustle,
tracking, …). Browse every operation, its required params and its defaults in the
`nba://stats/operations` resource.

### ESPN — `espn.com` JSON feeds

| Group | Tools | Notes |
|---|---:|---|
| `espn.scores` | 5 | Site API convenience endpoints: scoreboard, teams, standings, game summary, news |
| `espn.site` | 1 | `espn_site_call` — team detail, rosters, schedules, injuries, depth charts, transactions, history, athlete news, groups, rankings (10 ops) |
| `espn.core` | 1 | `espn_core_call` — the canonical `$ref`-linked model: events/competitions, odds, win-probability, plays, venues, drafts, coaches, calendar, transactions (37 ops) |
| `espn.web` | 1 | `espn_web_call` — site-wide search + `common/v3` athlete views (7 ops) |
| `espn.cdn` | 1 | `espn_cdn_call` — the CDN live core feed: scoreboard/game/boxscore/playbyplay (4 ops) |

All ESPN tools are parametric over `sport` + `league` slugs (e.g. `football`/`nfl`,
`basketball`/`nba`, `soccer`/`eng.1`), so the five groups cover **every** league ESPN
carries. Browse each dispatcher's operations in its `espn://{site,core,web,cdn}/operations`
resource.

### OpenF1 — `api.openf1.org` (Formula 1, no key)

| Group | Tools | Notes |
|---|---:|---|
| `openf1.reference` | 3 | Grand Prix weekends (meetings), sessions (the fixtures feed), driver roster |
| `openf1.results` | 5 | Session classification, starting grid, drivers'/constructors' championship standings, overtakes |
| `openf1.timing` | 5 | Per-lap sector + speed-trap timing, pit stops, tyre stints, live gaps/intervals, track position |
| `openf1.telemetry` | 2 | Car telemetry (speed/throttle/brake/gear/RPM/DRS) + (x,y,z) location at ~3.7 Hz |
| `openf1.live` | 3 | Race-control messages (flags/SC/incidents), team-radio clips, weather |

Free, no-auth public REST surface (`auth: none`). Scope feeds by `session_key` /
`meeting_key` (both accept the literal `latest`) and `driver_number`; discover keys
with `openf1_sessions` / `openf1_meetings` first.

### Cricket Australia — `cricket.com.au` (no key)

| Group | Tools | Notes |
|---|---:|---|
| `cricketaustralia.core` | 7 | Fixtures (the `/matches` feed), competitions, tours/series, teams, player profiles (batch), venue lookup, competition ladder |
| `cricketaustralia.match` | 3 | Full scorecard (innings batting/bowling/wickets), run-graph series, live video streams |
| `cricketaustralia.content` | 2 | Pulselive CMS: video/text/audio/playlist content list + curated playlists |

Two no-auth hosts (`apiv2.cricket.com.au/web` + the Pulselive CMS). The apiv2
endpoints carry `jsconfig=eccn:true` by default so they return the documented
camelCase shape; flow is `cricketaustralia_fixtures` → `cricketaustralia_scorecard?fixtureId=` →
`cricketaustralia_players?playerIds=`.

### MLB — `statsapi.mlb.com` (official Stats API, no key)

| Group | Tools | Notes |
|---|---:|---|
| `mlb.reference` | 22 | Sports/leagues/divisions/conferences, teams (+ single, affiliates, history, uniforms), rosters, alumni, coaches, personnel, players (profile, batch, search, season catalogue, changes feed), venues, seasons (current + full history) |
| `mlb.schedule` | 5 | Games by date / range / team, plus postseason (schedule, series, tune-in) and tied games |
| `mlb.game` | 10 | Boxscore, linescore, play-by-play, v1.1 `feed/live` firehose, win-probability, context metrics, content, per-player game line, changes, uniforms |
| `mlb.stats` | 9 | Standings, season stats, one-player stats, league + team leaders, team-season stats, game pace, high/low records |
| `mlb.extra` | 15 | Draft (+ prospects), awards (catalogue + recipients), attendance, transactions, free agents, jobs (umpires/datacasters/scorers), Home Run Derby, All-Star ballots |
| `mlb.meta` | 1 | `mlb_meta` — the `/{type}` lookup for every enum (positions, statTypes, gameTypes, pitchCodes, …) |

The official MLB Stats API the `MLB-StatsAPI` library wraps, read directly (no key) —
**comprehensive coverage of the public surface**. `sportId=1` is MLB; discover ids
with `mlb_teams` / `mlb_schedule` / `mlb_player_search`, then drill into a game or
player. Most tools accept the API's `hydrate` string to embed related objects in one
call.

### Premier League — `premierleague.com` (no key)

| Group | Tools | Notes |
|---|---:|---|
| `premierleague.core` | 7 | Competitions, season structure, awards, the league table, current gameweek, geo |
| `premierleague.teams` | 10 | Teams (+ batch), squads, form (single + all-teams), team stats, next fixture, club metadata |
| `premierleague.matches` | 8 | Fixtures/results feed + match centre: detail, events, lineups, team stats (~200 Opta metrics), officials, commentary |
| `premierleague.players` | 8 | Player directory, profiles (basic/career/season), batch lookup, season + competition stats, metadata |
| `premierleague.stats` | 2 | Player + team stat leaderboards (sort by any Opta metric) |
| `premierleague.content` | 8 | Editorial content/search, latest+popular news/video, broadcasting schedule |

The private JSON APIs that power premierleague.com, read directly (no key,
no cookies) across three hosts (the **SDP** stats platform, the editorial/
broadcast `api.premierleague.com`, and static config on `resources.premierleague.com`).
Underlying data is **Opta**. Premier League = competition `8`; season id is the
starting year (`2025` = 2025/26). Flow: `pl_teams` → `pl_matches` → a match id →
`pl_match`/`pl_match_stats`; `pl_standings` for the table. Unofficial/undocumented —
respect the ~5 rps rate limit. The SDP wire params (`_limit`, `_sort`,
`kickoff>`/`kickoff<`) are exposed under clean tool names (`limit`, `sort`,
`kickoff_after`/`kickoff_before`).

### LaLiga — `apim.laliga.com` (public key shipped)

| Group | Tools | Notes |
|---|---:|---|
| `laliga.core` | 6 | Competitions, season instances (subscriptions), league table, rounds/matchweeks |
| `laliga.teams` | 3 | Season team list, single team, club squad |
| `laliga.players` | 3 | Every-player season stats (≈749, full Opta metrics), player profile + stats |
| `laliga.matches` | 2 | Matches feed + single-match detail |

The private JSON API behind laliga.com (Azure APIM), read directly. Underlying
data is **Opta**. A **public** `Ocp-Apim-Subscription-Key` is **shipped as a
working default**, so it runs out of the box — but the key rotates; override it
with `LALIGA_SUBSCRIPTION_KEY` (env or `secrets:`) when reads start 401-ing
(re-harvest from laliga.com's `__NEXT_DATA__`). A "subscription" is a season
instance (slug `laliga-easports-2025` = 2025/26); detail endpoints are keyed by
**slug**. Pairs with the Premier League provider for cross-league football
comparison via the shared `stats.ladder` / `sport.fixtures_by_date` /
`stats.player_season` tags.

### Serie A — `api-sdp.legaseriea.it` (no auth)

| Group | Tools | Notes |
|---|---:|---|
| `seriea.core` | 3 | All competitions, the 41-season catalogue, single-season detail |
| `seriea.season` | 6 | League table (overall/home/away), the 20 teams, every-player + team Opta stats (paginated), all 380 matches, match lineups |

The public **SDP** JSON API behind legaseriea.it, read directly (no auth).
Underlying data is **Opta**. The Serie A competition id is baked in, so you only
ever supply a `seasonId` (discovered from `seriea_seasons`; `seasonName` like
`2025/2026`). Player stats return identity **and** ~279 Opta metrics in one call
(no squad endpoint), paginated 30/page with `category=General|Goalkeeping`.
Completes the big-three football leagues alongside Premier League + La Liga via
the shared `stats.ladder` / `sport.fixtures_by_date` / `stats.player_season` tags.

### Kalshi — `kalshi.com` (prediction markets, no key)

| Group | Tools | Notes |
|---|---:|---|
| `kalshi.markets` | 6 | Market catalogue + detail, order book, public trades, OHLC candlesticks (single + batch) |
| `kalshi.events` | 9 | Events, series catalogue (by category), single series, milestones, MVE combo collections, entity registry |
| `kalshi.exchange` | 3 | Exchange status, trading schedule, announcements |

The CFTC-regulated US event-contract exchange. **Market data is public — no
key required**; optionally set `KALSHI_API_KEY_ID` + `KALSHI_PRIVATE_KEY`(`_PATH`)
and every request is RSA-signed for Kalshi's higher authenticated rate limits
(needs `pip install "sportsdata-mcp[kalshi-auth]"`). Trading surfaces stay out
of scope (read-only provider). Id chain:
`kalshi_series_list(category)` → `kalshi_events` → `kalshi_markets` →
orderbook/trades/candles by ticker. Prices are dollar-denominated.

### Polymarket — `polymarket.com` (prediction markets, no key, geo-gated)

| Group | Tools | Notes |
|---|---:|---|
| `polymarket.gamma` | 9 | Markets/events/series/sports/tags catalogue + site search (the discovery plane) |
| `polymarket.clob` | 6 | Order book, best price, midpoint, spread, price history, CLOB catalogue |
| `polymarket.data` | 2 | Public trade tape + top holders |

The largest crypto prediction market. **All read endpoints are anonymous** —
the wallet keys Polymarket's SDKs use are for order placement only (out of
scope). ⚠️ **Geo-gated**: Polymarket drops connections at the network edge
from restricted jurisdictions (verified: AU IPs time out on every host) — run
from an unrestricted region or VPN. Flow: `polymarket_events` → a market's
`clobTokenIds` → `polymarket_book` / `polymarket_price_history`.

### X (Twitter) — `api.x.com` (needs a Bearer token)

| Group | Tools | Notes |
|---|---:|---|
| `twitter.tweets` | 7 | 7-day search, volume counts, post lookup (batch + single), quote/repost/like engagement |
| `twitter.users` | 6 | Profile lookup (handle/id, batch), user timelines, mentions |
| `twitter.trends` | 2 | Trends by location (WOEID) + project usage/cap monitor |

The X API v2 read surface — **no anonymous tier**, so a Bearer token is
required: env `X_BEARER_TOKEN` first (an operator can ship a deployment-wide
token for all its users), then the config `secrets:` block (each user their
own). The env var holds the bare token; the spec adds `Bearer `. Mind your
tier's monthly read cap (`twitter_usage`); the spec throttles ~0.5 req/s and
never auto-retries 429s. Write/user-context surfaces (posting, DMs, follows)
are out of scope. Flow: `twitter_user_by_username("AFL")` → id →
`twitter_user_tweets`; search with X operators (`"Storm" lang:en -is:retweet`).

## Cross-provider comparison

Every tool is tagged with provider-agnostic **capability** slugs (e.g.
`sport.event_markets`, `racing.race_card`). Tools sharing a slug answer the same
question and are directly comparable across providers. The discovery flow:

1. `list_tools_by_capability("sport.event_markets")` → every enabled tool exposing it
2. Call each provider's tool concurrently with the resolved event ids
3. Compare the raw snapshots (schemas are **not** normalised — the model reconciles them)

See [`examples/comparator-prompt.md`](./examples/comparator-prompt.md) for a full
"compare Storm v Cowboys odds across bookies" walkthrough.

## Per-provider notes

- **Sportsbet** — anonymous public APIs; no secrets needed. REST events are keyed
  by integer `eventId`; a persisted-GraphQL gateway is exposed via
  `sportsbet_graphql_call` (browse `sportsbet://graphql/operations`).
- **Entain / Ladbrokes** — a persisted-GraphQL gateway; the model supplies an
  operation name + variables (discover them in `entain://graphql/operations`).
  Hashes can drift when the front-end bundle ships; refresh them with
  `sportsdata-mcp refresh-hashes entain`.
- **AFL** — `afl.public.*` is anonymous. `afl.premium.*` mints an anonymous
  `x-media-mis-token` automatically; some premium endpoints still return 401 for
  anonymous callers.
- **NRL** — the anonymous Champion Data match-centre CDN (`mc.championdata.com`),
  the same static JSON the official nrl.com match centre reads. No secrets, no
  cache-buster params needed. Resolve a `competitionId` from `nrl_competitions`
  (e.g. 12999 = 2026 NRL Premiership), a `matchId` from `nrl_fixture`, then pull
  per-player match stats from `nrl_match`; decode stat codes via
  `nrl://stats/definitions`.
- **NBA** — two surfaces, no secrets. `cdn.nba.com` is wide open (it even serves
  JSON as `text/plain`, which the client accepts). `stats.nba.com` sits behind
  Akamai, which black-holes any request missing a full browser header bundle — the
  spec ships that bundle in `provider.default_headers`, so it just works. Akamai also
  rate-limits hard, so the spec's `defaults` block throttles NBA to ~1 req/2.5 s,
  sets a 45 s timeout, and retries transient `429/5xx` with exponential backoff (all
  overridable via `providers.nba.*`). The `/stats/` family is one dispatcher
  (`nba_stats_call`): pick an `operation` (the path segment, e.g.
  `leaguedashplayerstats`) and pass `query_params` — each operation already carries
  NBA's full default param set, so you override only what matters. Most responses are
  column-oriented (`resultSets:[{name, headers, rowSet}]}`); v3 box scores are nested.
- **ESPN** — four public hosts, **no auth, no API key**: `site.api.espn.com` (scores,
  teams, standings, news, summaries), `sports.core.api.espn.com` (the canonical
  `$ref`-linked model — odds, win-probability, plays, venues, drafts, coaches),
  `site.web.api.espn.com` (search + athlete views) and `cdn.espn.com` (the live core
  feed, needs `?xhr=1`). Nearly every URL is `.../sports/{sport}/{league}/{resource}`,
  so the tools take `sport` + `league` as parameters and cover every ESPN league
  parametrically — NFL, NBA, MLB, NHL, college, soccer (`eng.1`, `esp.1`, …), golf,
  racing, tennis, MMA and more. Discovery: `espn_scoreboard(sport, league)` → an `event`
  id → `espn_game_summary` or the deep `espn_core_call(event_*)` ops. The spec throttles
  to ~5 req/s and retries transient `429/5xx` (overridable via `providers.espn.*`). Note
  the core API path uses `leagues/{league}` (plural); core list responses are lazy
  `{count, items:[{$ref}]}` envelopes — follow the refs for detail.
- **PointsBet** — anonymous public APIs, no secrets. `api.au.pointsbet.com` serves
  the sportsbook (sports + racing); `pointsbet.com.au` serves static CMS/nav assets
  via the `pointsbet_content_call` dispatcher. Sports discovery:
  `pointsbet_sport_competitions(sportKey)` → a competition key → `pointsbet_event(eventKey)`
  for the full market book. Racing: `pointsbet_racing_meetings(startDate, endDate)` →
  a `raceId` → `pointsbet_racing_race`. Many feeds return a top-level JSON array.
- **TAB (Tabcorp)** — anonymous public data, no secrets. `api.beta.tab.com.au`
  sits behind Akamai (the spec ships a browser header bundle + ~2.5 rps throttle,
  like NBA); `cmsapi.tab.com.au` serves CMS feeds via `tab_cms_call`. Every
  endpoint needs a `jurisdiction` (defaults to `NSW`). The API is HATEOAS and
  **name-based** — paths embed sport/competition/match/venue names with spaces
  (`…/AFL Football/competitions/AFL/matches/Adelaide v Geelong`), which the HTTP
  layer percent-encodes; pass raw names. Racing: `tab_racing_meetings(date)` →
  `raceType`+`venueMnemonic` → `tab_racing_race`. Sports:
  `tab_sport` → `tab_competition` → `tab_match` for the full market book.
- **Unibet** — anonymous AU data, no secrets, two surfaces. **Racing** is
  persisted-GraphQL (`unibet_racing_call`, the `graphql_persisted` dispatcher) at
  `rsa.unibet.com.au` — race ids are `eventKey`s like
  `202606040200.T.AUS.hawkesbury.1`; the endpoint enforces Apollo CSRF so a
  `Content-Type: application/json` header is sent. **Sport** is the **Kambi**
  offering API (`unibet_kambi_call` over `*.kambicdn.com`, market AU): group tree,
  events, bet offers, in-play, bet-builder. Browse ops in
  `unibet://{racing,sport}/operations`.
- **BetR** — anonymous AU data, no secrets. BetR runs on the **BlueBet** platform,
  so the API is `web20-api.bluebet.com.au` — a flat REST surface covering racing
  (next-to-jump, grouped racecards, race cards, form, fluctuations) and sport
  (event types → categories → markets, SGMs). The `betr.com.au` Next.js
  `_next/data/{buildHash}` blobs are skipped (fragile per-deploy hash; the API
  serves the same data).
- **Racing and Sports** — `www.racingandsports.com.au` racing/form data, no auth.
  `racingandsports_todays_racing` (`/todays-racing-json-v2`) is the verified feed —
  today's meetings across thoroughbred/harness/greyhound, by country. The site is
  behind Cloudflare, which whitelists that feed but JS-challenges the other paths
  from datacenter IPs (they work from a residential/browser IP); the form/fields/
  results are HTML pages, and `GetOdds` needs a per-race token, so only the JSON
  feeds are modelled.
- **Betfair Exchange** — anonymous, the open read-only web APIs keyed by the public
  `_ak` query param. The crown jewel is `betfair_market_prices` (`ero …/bymarket`) —
  exchange **back/lay** prices, the sharpest reference odds. Discover market ids by
  walking `betfair_navigation` (`scan …/bynode`, e.g. `EVENT_TYPE:7` = Horse Racing)
  down to MARKET nodes; live scores/details come from the `ips` in-play service.
  `string_csv` id params take a list. (The `apieds` racing widgets are Cloudflare-gated
  from datacenter IPs and the `appsync` GraphQL needs a session, so they're out of
  scope — racing is covered via navigation→bymarket.)
- **Pinnacle** — anonymous, no key. The Arcadia "guest" API
  (`guest.api.arcadia.pinnacle.com`) — the open feed the web sportsbook reads.
  Sports only (sharp-odds book, no racing); prices are American odds. Flow:
  `pinnacle_sports` → `pinnacle_sport_matchups(sportId)` → `pinnacle_matchup_markets(matchupId)`.
  The provider sends Pinnacle's public web-client `X-API-Key`, which unlocks the
  full per-sport + per-league matchup lists and the parlay markets.
- **FanDuel (US)** — anonymous US data, no secrets, two surfaces under one provider.
  **Racing** is the first **full-query GraphQL** provider: `fanduel_racing_call`
  POSTs the literal query text (the `graphql_query` dispatcher kind, sibling to the
  persisted-hash `graphql_persisted`), with boilerplate variables
  (`brand`/`product`/`device`/profile) baked as per-op `default_variables` — most
  calls need none, override only what varies (`{results: 12}`, `{trackCode, raceNumber}`).
  **Sportsbook** is REST (`fanduel_sb_call`) keyed by the static public `_ak` web key,
  region NJ. The two halves need different `Origin` headers, so the sportsbook
  dispatcher overrides `Origin` + `x-sportsbook-region` over the racing-origin
  provider default. Browse ops in `fanduel://{racing,sportsbook}/operations`.
  (US data — composes with other US sources via capability tags.)

## CLI reference

| Command | Purpose |
|---|---|
| `serve` | Start the MCP stdio server (default when no subcommand) |
| `list-groups` | Print every group with tool count + description |
| `lint` | Validate specs against the schema + capability catalogue (nonzero on failure) |
| `doctor` | Per-provider reachability + auth + REST-contract probe (nonzero on failure) |
| `refresh-hashes <provider>` | Refresh persisted-query hashes from the live front-end bundle (`--dry-run` to preview) |
| `version` | Print version info |

`-v` / `--verbose` enables DEBUG logging (and un-silences `httpx`/`httpcore`).

## Contributing

**See [`documentation/ADDING_A_PROVIDER.md`](./documentation/ADDING_A_PROVIDER.md)** for
the full guide, with separate playbooks for adding a **bookmaker** vs a **sports website /
data API**. In short, adding a provider is a spec-only change in the common case:

1. Write `src/sportsdata_mcp/specs/<provider>.yaml` (copy an existing spec).
2. Tag each tool with capability slugs from `specs/_capabilities.yaml`; add a new
   slug there if none fits (two providers sharing a slug makes them comparable).
3. `sportsdata-mcp lint` — must pass.
4. `sportsdata-mcp doctor` (with the new groups enabled) — probes it live.
5. `pytest -m "not live"` — offline suite; drop the marker filter to run live tests.
6. Add a row to `tests/contract/test_api_contracts.py` so the new provider's
   documented response shape is verified live on every PR (see below).

```bash
pip install -e ".[dev]"
pytest -m "not live"      # offline suite (the CI gate)
pytest -m contract        # live response-contract checks (see below)
ruff check .
```

### CI

Every push/PR runs three jobs (`.github/workflows/ci.yml`):

- **test** — ruff, `sportsdata-mcp lint`, and the offline suite (`pytest -m "not live"`)
  across Python 3.11–3.13. The deterministic gate.
- **contract** — `pytest -m contract`: live response-contract checks that hit each
  upstream API and assert it still returns the **documented** shape (top-level keys,
  and the documented keys on list items). It is resilient by design — it **skips**
  on anything outside our control (network errors, `5xx`, `401/403/429`, geo-blocks,
  a missing `DATAGOLF_KEY`, or an empty feed) and only **fails** on a genuine shape
  regression or a broken spec (wrong path/params → `4xx`). Bookmaker APIs that
  geo-block GitHub's runners simply skip there.
- **package** — builds the wheel and proves the CLI loads the packaged specs from a
  clean install.

## License

**Proprietary and confidential.** Copyright (c) 2026 Daniel Tomaro. All rights
reserved. No use, copying, modification, or distribution is permitted without the
owner's prior written consent — see [`LICENSE`](./LICENSE).
