gameday-gateway
Provides read-only tools to query NFL and college football analytics data stored in Snowflake, including player and team performance metrics such as EPA per play, success rate, and down tendencies.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@gameday-gatewayWho led the NFL in EPA per play in 2024?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Fourth&Data
When it's fourth and short, ask the data.
Fourth&Data is a free, public MCP server that turns 26 seasons of NFL play-by-play (1999–2025) and 24 of college football (2002–2025) into analyst answers. Ask your AI assistant a football question in plain English; it calls curated tools and answers with the verdict first and the evidence attached — league ranks, trends, sample sizes, and only the caveats that actually apply.
Connect
Endpoint: https://fourthanddata.fly.dev/mcp
Claude (claude.ai or Desktop): Settings → Connectors → Add custom connector → paste the endpoint URL. Or with Claude Code:
claude mcp add --transport http fourthanddata https://fourthanddata.fly.dev/mcpCursor: add to ~/.cursor/mcp.json:
{ "mcpServers": { "fourthanddata": { "url": "https://fourthanddata.fly.dev/mcp" } } }ChatGPT: Settings → Connectors (developer mode) → add the endpoint URL.
Then just ask: "Lions 4th-and-2 at the 34, down 4, 6:10 left — do they go?"
Free tier: no key needed, ~60 requests/hour per IP. The gateway may briefly serve cache-only answers under heavy load (it says so honestly when it does). No accounts, no tracking — client IPs are salted-hashed before they touch a log.
Related MCP server: nfl-mcp
Data, attribution, honesty
NFL data from nflverse (CC-BY 4.0 — thank you). College data from cfbfastR / sportsdataverse and the CollegeFootballData.com API.
College EPA for 2022–2025 comes from our own model (
cfb_ep_v1, validated r = 0.973 against cfbfastR's reference EPA); those seasons are labeled with their fidelity in every answer that uses them.Win-probability values are empirical — the share of games actually won from each state since 1999 — not a simulation. Every tool's envelope carries a
methodologyfield saying exactly how its numbers are built.Coverage edges: PROE needs 2006+; NGS/FTN-derived context is 2016+/2022+; college player attribution in the API era (2022+) is parsed from play text (~87% coverage, labeled).
Not affiliated with, endorsed by, or connected to the NFL, the NCAA, any team, or any data provider. For entertainment and analysis; nothing here is betting advice.
Tools — the Fourth&Data launch toolset
Every tool answers in the same envelope: answer (analyst read, verdict first),
headline, values, context (rank/percentile/trend), sample, fidelity,
season_type, methodology (standing method notes — always true of the tool),
caveats (situational only — thin sample, ambiguous name, fidelity seam), sources.
The caveat discipline is a calibration ruling: what is always true lives in
methodology; prose carries only what this answer tripped.
Tool | What it answers | Example prompt |
| Go, kick, or punt — the signature call | "Lions 4th-and-2 at the 34, down 4, 6:10 left — do they go?" |
| Multi-season arc, NFL/CFB/both | "How has Joe Burrow's efficiency moved year over year?" |
| Where a season came from (descriptive PDR layer) | "Break down Tua's 2023 — how much was the system?" |
| Raw vs opponent-adjusted EPA, gap called out | "Was Goff's 2024 inflated by the schedule?" |
| Statistical college comps + what happened to them | "Who does Jayden Daniels comp to statistically?" |
| What a team actually does, vs league | "What's the Ravens' identity on offense in 2024?" |
| Unit-collision analysis + head-to-head | "Chiefs–Bills: who has the edge and where?" |
| Rising usage/efficiency signals, no projections | "Which WRs are trending toward a breakout?" |
| Oddities ranked by rarity vs 26 years | "What was weird in week 12 of 2024?" |
| Deep-history leaders/records/counts, filters done right | "Most 4th-quarter comeback wins since 1999?" |
| QB leaderboard by EPA per pass play | "Who led the NFL in EPA per play in 2024?" |
| Quick two-team EPA side-by-side | "Chiefs or Bills in 2024 — who was better?" |
| Run/pass split and EPA by down | "What do the 49ers call on 2nd down?" |
| Performance in a named situation, vs league | "How does Mahomes play on third and long?" |
| Who a player is: college, size, draft, ids | "Where did Puka Nacua go to college?" |
| Injury designations from the daily snapshot | "Who's hurt on the Chiefs?" |
| Weather and venue profile for a team or stadium | "How windy are Bills games?" |
| Record, streak, seed — proxied live from ESPN | "What's the Panthers' record?" |
| Live/scheduled games right now — proxied, uncached | "Did the Eagles win?" |
| One player's career totals + milestone distance | "How many yards does Derrick Henry need for 12,000?" |
| ATS and over/under record vs the closing line | "Do the Bills cover more often at home?" |
| Consensus spread/total for upcoming games | "What's the Chiefs' spread?" |
| Gateway internals | — |
season_type accepts REG (default), POST, or ALL. It defaults to regular season
because that is what "the 2024 season" means in almost every football question — letting
playoffs leak in silently inflates play counts and shuffles leaderboards.
Note that get_qb_epa_leaders measures EPA per pass play (attempts and sacks).
Designed QB runs and scrambles are classified as runs upstream and are excluded, so
mobile quarterbacks are understated relative to an all-plays EPA metric.
MART_QB_SEASON (behind explain_production and player_trajectory) coalesces
scrambles back in via the rusher attribution, so those tools carry the all-dropback number.
fourth_down_verdict — how the call is made
Each choice is valued by empirical win probability: league conversion rates by
distance × field zone, modern-era FG make rates by kick distance, expected punt nets —
each branch resolved against a WP surface built from actual game outcomes of every
1st-down state since 1999 (not a model's opinion). Three surfaces: fine
(10-yd × score-diff × 7.5-min buckets), a coarse fallback for thin cells, and an
endgame surface (final 5 minutes: 60-second buckets, exact score diff) so the
clock is never erased in states where the clock is the state. For flat early-game
toss-ups, ordering ties break on the state-only WP model — disclosed in
sample.tiebreak when used. Every answer carries the coach-behavior line (how often
coaches actually went in this spot, 2015 vs latest), and toss-ups end with the
one-sentence tiebreaker: what would tip the call.
What it can and cannot answer
docs/verification/capability-eval.md scores 797 real-shaped questions
through the live tools — CORRECT / WRONG / GAP, with a plausible-sounding wrong
answer counted as WRONG rather than partial credit. Current: 76.2% answered,
zero wrong, up from 47.9% before this phase. Failures are clustered by the
capability they need, which is what turns an eval into a build list.
It earns its keep by finding silent defects: a leaderboard that ranked on its own rounded display column (so the 2021 EPA "leader" was whatever Snowflake happened to emit among three QBs tied at 0.185), a rate that reported 84 of 67, a situational predicate that could not execute, and a bio lookup that answered "Josh Allen" with a centre who retired in 2016.
Tests
pytest tests/ runs against live Snowflake:
Sanity (
test_sanity_fourth_down.py): ~10 obvious-answer situations — must-punt, obvious-go, never-punt-from-scoring-range — that fail loudly on regressions like the punt-geometry inversion. Wired into CI (.github/workflows/ci.yml; runs whenSNOWFLAKE_*secrets are configured).Smoke (
test_smoke_tools.py): every tool with real params, envelope shape checked.
Development & architecture
Everything below is for people running or extending the warehouse and gateway.
Architecture (v0.1)
nflreadpy ─────parquet──▶ @INGEST_STAGE ──COPY INTO──▶ GAMEDAY.RAW (loader role)
cfbfastR-data ─parquet──▶ @INGEST_STAGE ──COPY INTO──▶ GAMEDAY.RAW_CFB (loader role)
CFBD API ──────parquet──▶ │
dbt ──▶ GAMEDAY.MARTS
│
public ◀─streamable HTTP (Fly.io)─▶ FastMCP ──read-only role┘
local ◀─stdio────────────────────▶ server │
ops layer: per-IP rate limit · └▶ GAMEDAY.OPS.TOOL_CALLS (audit)
TTL cache · concurrency cap ·
query circuit breaker (cache-only mode)The deployed gateway (MCP_TRANSPORT=http) adds an ops layer (server/ops.py):
per-IP sliding-window rate limiting with an optional env-configured API-key tier
(API_KEYS="key:limit,..."), salted-IP-hash audit logging batched to
GAMEDAY.OPS.TOOL_CALLS (the reader role's single, deliberate INSERT grant —
see sql/setup_ops.sql), a global concurrent-query cap, per-query timeouts,
and a circuit breaker that flips to cache-only mode on anomalous query volume.
Cost stack, outermost first: rate limit → cache → concurrency cap → breaker →
45s query timeout → 60s warehouse statement ceiling → 30-credit monthly
resource monitor (hard suspend).
Setup
1. Snowflake trial
Sign up at signup.snowflake.com (30 days / $400 credits, no card). Pick AWS + a nearby region. Note your account identifier (Admin → Accounts, format like ABC12345.us-east-1).
2. Key-pair auth (Snowflake now requires MFA/keys for programmatic access)
openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out sf_key.p8 -nocrypt
openssl rsa -in sf_key.p8 -pubout -out sf_key.pubIn a Snowflake worksheet (paste the pub key contents, minus header/footer lines):
ALTER USER YOUR_USERNAME SET RSA_PUBLIC_KEY='MIIBIjANBgkq...';3. Create the Snowflake objects
Run sql/setup.sql in a worksheet as ACCOUNTADMIN (edit YOUR_USERNAME at the bottom first). This creates the database, RAW/MARTS schemas, an XSMALL warehouse with 60s auto-suspend and a 60s statement timeout, a 30-credit/month resource monitor, and the loader/reader roles.
4. Local environment
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # fill in account, user, key path
set -a; source .env; set +a5. Ingest
python ingest/ingest.py --seasons 2023 2024 --dry-run # verify data pull works
python ingest/ingest.py --seasons 2023 2024 # full load to Snowflake
python ingest/ingest.py --recreate --skip-export # rebuild schema from local parquet--skip-export reuses whatever is already in data/*.parquet instead of re-pulling
nflverse. --recreate DROPs each table before rebuilding it — required after any change
to the schema template, since CREATE TABLE IF NOT EXISTS silently keeps the old shape.
--fresh ignores the checkpoint and re-pulls.
Every (dataset, season) pull is checkpointed as a parquet shard under data/shards/,
so an interrupted run resumes instead of restarting. Seasons the upstream does not
publish are recorded as unavailable and not retried.
Season coverage is one constant. LATEST_SEASON in ingest/ingest.py drives every
dataset's season list; bumping it for 2026 is a one-line change.
5b. College ingest (CFB)
First create the schema — the loader role cannot, so run sql/setup_cfb.sql in Snowsight
as ACCOUNTADMIN (it is one BEGIN...END block because the worksheet rejects
multi-statement pastes). Then:
python ingest/cfbd_ingest.py --bulk-only # zero API calls
python ingest/cfbd_ingest.py --estimate-only # print planned cost, spend nothing
python ingest/cfbd_ingest.py --with-pbp-api # include metered play-by-play
python ingest/cfbd_ingest.py --max-calls 60 # tighter ceiling for one runThe ingest is bulk-first regardless of tier: anything published by cfbfastR-data is
downloaded from GitHub at zero API cost, and the API covers only what bulk does not.
Calls are counted in data/cfb/.cfbd_quota.json, which warns at 25/50/75% and
hard-stops before the monthly limit. Every run prints its planned call count before
spending anything, and --estimate-only prints it and exits.
Two season ranges, deliberately separate: BULK_EARLIEST (2002) and API_EARLIEST
(2015). Widening the free bulk history can never silently widen a metered API pull.
Current tier is Tier 2 ($5/mo, 30,000 calls/month, play-by-play unlocked). Set
TIER_NAME/TIER_MONTHLY if that changes. Reference costs: recruiting + portal +
returning production for 2015–2025 is 38 calls; play-by-play for 2022–2025 is 89, since
/plays is week-scoped (62 regular-season weeks + 27 postseason probes).
5c. Entity resolution (crosswalk marts)
Run after both ingests. No API key, no quota — ESPN team lists and the Sleeper player dump are free and keyless, and both are cached for 24h.
python ingest/xwalk_teams.py --dry-run # parquet + report, no Snowflake
python ingest/xwalk_teams.py # -> MARTS.MART_TEAM_XWALK/_ALIASES
python ingest/xwalk_players.py # -> MARTS.MART_PLAYER_XWALK
python ingest/xwalk_players.py --validate-fuzzy # held-out precision measurement
pytest tests/test_xwalk.py # 34 invariant + redteam tests, offlineHuman corrections go in data/xwalk/player_overrides.csv as link or unlink
assertions; an unlink outranks any amount of automatic agreement.
5d. The gateway's own identity (read-only, verified by attempting writes)
The public endpoint authenticates as GAMEDAY_GATEWAY, a user that holds
exactly one role and has DEFAULT_SECONDARY_ROLES = (). It is deliberately not
the user the ingest scripts use — see Lessons learned, "A read-only role is not a
read-only session". Create it with sql/setup_readonly_hardening.sql as
ACCOUNTADMIN, give it its own key pair, and point the server at it:
# local (.env)
SNOWFLAKE_GATEWAY_USER=GAMEDAY_GATEWAY
SNOWFLAKE_GATEWAY_PRIVATE_KEY_PATH=/absolute/path/gateway_key.p8
# Fly — pass the key CONTENTS, not a path
fly secrets set SNOWFLAKE_GATEWAY_USER=GAMEDAY_GATEWAY
fly secrets set SNOWFLAKE_GATEWAY_PRIVATE_KEY="$(cat gateway_key.p8)"Both fall back to SNOWFLAKE_USER / SNOWFLAKE_PRIVATE_KEY_PATH when unset,
which is convenient locally and wrong in production.
Verify the posture the only way that means anything — by attempting what it
forbids, not by reading SHOW GRANTS:
pytest tests/test_readonly_posture.py -vThat asserts SELECT works across RAW/RAW_CFB/MARTS and that INSERT, UPDATE, DELETE, DROP, CREATE, TRUNCATE and DROP DATABASE are all refused.
5e. Live layer (ESPN, Sleeper, weather)
First create the schema — the loader role cannot, so run sql/setup_live.sql in
Snowsight as ACCOUNTADMIN (one BEGIN...END block, same as the CFB schema).
python ingest/espn.py --probe # health-check all 8 endpoints
python ingest/espn.py --snapshot-injuries # daily -> RAW_LIVE.ESPN_INJURIES
python ingest/sleeper.py --snapshot-trending # daily -> RAW_LIVE.SLEEPER_TRENDING
python ingest/stadiums.py --validate # check every coordinate
python ingest/stadiums.py # -> MARTS.MART_STADIUMS
python ingest/weather.py --league NFL --estimate-only # planned call count
python ingest/weather.py --league NFL --backfill # 868 calls, resumable
python ingest/weather.py --league NFL --validate # vs nflverse temp/wind
python ingest/weather.py --league NFL --forecast # next 14 daysLive state is proxied, not mirrored. A scoreboard is worthless five minutes
later and ESPN grants no licence to archive it, so only two feeds are
snapshotted — the ones whose history cannot be reconstructed afterwards:
injury listings ("who was questionable on Thursday") and Sleeper trending
adds/drops (a same-day-only feed). Everything else is fetched on demand through
ingest/livefetch.py, which caches to disk with a per-endpoint TTL and degrades
to cache, then to None, rather than raising. A dead upstream must cost an
answer, not the gateway.
6. Wire into Claude Desktop
claude_desktop_config.json:
{
"mcpServers": {
"gameday": {
"command": "/absolute/path/.venv/bin/python",
"args": ["/absolute/path/server/server.py"],
"env": {
"SNOWFLAKE_ACCOUNT": "ABC12345.us-east-1",
"SNOWFLAKE_USER": "YOUR_USERNAME",
"SNOWFLAKE_PRIVATE_KEY_PATH": "/absolute/path/sf_key.p8"
}
}
}
}Restart Claude Desktop, then ask: "Who led the NFL in EPA per play in 2024?"
Data inventory
GAMEDAY.RAW — NFL, 30 tables, 5,780,790 rows, 682 MB in Snowflake. Every table verified row-for-row against its source parquet.
Table | Seasons | Rows | Cols |
| 1999–2025 | 1,279,628 | 372 |
| 2001–2025 | 1,423,400 | 26 |
| 2002–2025 | 906,378 | 36 |
| 2016–2025 | 478,989 | 26 |
| 1999–2025 | 476,156 | 145 |
| 2012–2025 | 324,611 | 16 |
| 2022–2025 | 185,215 | 29 |
| 1920–2025 | 139,685 | 36 |
| 2006–2025 | 112,297 | 159 |
| 2009–2025 | 90,752 | 17 |
| 2018–2025 | 62,345 / 35,724 / 18,461 / 5,424 | 16–29 |
| all | 51,803 | 37 |
| 1999–2025 | 49,514 | 143 |
| all | 25,035 | 39 |
| 2015–2025 | 21,900 | 9 |
| 2016–2025 | 14,731 / 6,059 / 5,933 | 22–29 |
| 1999–2025 | 14,531 | 133 |
| all | 12,670 | 36 |
| current | 12,470 | 35 |
| 2000–2025 | 8,649 | 18 |
| 1999–2025 | 7,276 | 46 |
| current | 5,281 | 25 |
| all | 4,975 | 11 |
| 1999–2025 | 862 | 131 |
| current | 36 | 16 |
GAMEDAY.RAW_CFB — college, 10 tables, 5,163,561 rows, 1,009 MB in Snowflake. Every table verified row-for-row against its source parquet.
Table | Seasons | Rows | Cols | Source | API calls |
| 2002–2021 | 2,456,357 | 701 | bulk | 0 |
| 2014–2025 | 1,637,447 | 70 | bulk | 0 |
| 2022–2025 | 648,121 | 30 | CFBD API | 89 |
| 2004–2025 | 282,157 | 18 | bulk | 0 |
| 2015–2025 | 45,735 | 20 | CFBD API | 11 |
| 2004–2025 | 39,323 | 29 | bulk | 0 |
| 2002–2025 | 36,231 | 31 | bulk | 0 |
| 2021–2025 | 14,422 | 10 | CFBD API | 5 |
| 2015–2025 | 2,338 | 5 | CFBD API | 11 |
| 2015–2025 | 1,430 | 15 | CFBD API | 11 |
GAMEDAY.MARTS — entity resolution, 3 tables, 139,012 rows.
The crosswalk layer every cross-source answer joins through. Built by
ingest/xwalk_players.py and ingest/xwalk_teams.py; contract, invariants and
validation in docs/verification/.
Table | Rows | What it is |
| 131,814 | One row per person, 29 id namespaces wide (gsis, pfr, espn, sleeper, cfbd, cfbref, sportradar, yahoo, pff, esb, smart, oddsjam, kalshi, …) with |
| 5,279 | Every string that resolves to a franchise — abbreviations, names, nicknames, alt names, slugs — with season validity and an |
| 1,919 | One row per franchise (32 NFL active + 43 historical, 1,844 CFB) across nflverse / ESPN / CFBD / Sleeper / odds ids |
Two deterministic namespace facts do most of the work, both verified against
the data rather than assumed: CFBD's athlete_id is the ESPN athlete id
(4,609 exact joins, 96.2% surname agreement), and CFBD's team_id is the ESPN
team id (752 of ESPN's 755 college teams). That turns college↔pro from a fuzzy
name match into an id equality for everyone it covers.
Match rates, scoped to each namespace's era of existence — a 1955 player cannot have an ESPN id, and quoting a whole-history rate would just be measuring how old the archive is:
Population | GSIS → ESPN | → PFR | → CFBD | → Sleeper |
played 2001+ (n=15,678) | 92.4% | 89.7% | — | — |
rookie 2006+ (n=11,930) | 94.0% | — | 76.1% | — |
active 2015+ (n=8,892) | 99.7% | — | 81.0% | 84.0% |
131,814 identities, 0 unresolved conflicts, mean confidence 0.945. The remaining CFBD gap is mostly not a matching failure: of players in the CFBD era with no college link, 80.9% do not appear in CFBD's roster files under any name.
Merges are refused, not scored. A missed link costs a "not found"; a false
link silently attributes one man's career to another inside an answer the user is
told is evidence-backed. So the union-find refuses any merge that would combine
two different league-issued anchor ids, records every refusal with a reason, and
the fuzzy tier is bounded by measurement: 99.82% person-precision at 98.2%
recall against a held-out set of 4,977 id-proven links
(docs/verification/xwalk-eval.md). pytest tests/test_xwalk.py — 34 tests —
fails the build if that slips below 99%.
GAMEDAY.MARTS.CFB_PBP_EPA — unified college EPA, 3,011,211 rows, 2004–2025.
Our own EP/EPA/WP model (cfb_ep_v1) scores both pbp eras into one mart with
OUR_EP, OUR_EPA, OUR_WP and a GARBAGE_TIME flag — closing the gap that
2022–2025 API plays carry no EPA. Validated against cfbfastR's reference EPA at
r = 0.973 across 1.96M bulk-era plays (gate was 0.95); externally
cross-checked at r = 0.83 vs CFBD's independent ppa on the API era. Model
details, guards and limitations: docs/EPA_MODEL.md.
Play-by-play spans 2002–2025 across two deliberately separate tables. Bulk
cfb_pbp (2002–2021) is cfbfastR's enriched ~700-column frame — EPA, win probability,
participation. cfb_pbp_api (2022–2025) is the raw ~30-field CFBD play record, fetched
week-by-week on Tier 2, with season/season_type/week stamped at fetch time since
/plays echoes none of them. They are not unioned: a merged table would be mostly-null
with column meaning depending on era. CFBD files every bowl and playoff game as
postseason week 1.
Design decisions (read before interviews)
No
query(sql)tool. The model is an untrusted query author; curated parameterized tools + a read-only role with statement timeouts are the mitigation. Raw SQL access from an LLM is a prompt-injection → data-exfiltration vector.INFER_SCHEMA + COPY INTO instead of row inserts: the standard Snowflake bulk pattern, and nobody hand-writes a 372-column DDL. The template
UPPER()s the inferred column names — see Lessons learned.TTL cache in the gateway so repeat questions never resume the warehouse — compute cost control lives in the app layer and in AUTO_SUSPEND.
TRUNCATE + full reload is deliberate v1 simplicity; incremental merge comes with dbt.
Lessons learned
Parquet + INFER_SCHEMA gives you case-sensitive columns. CREATE TABLE … USING TEMPLATE (INFER_SCHEMA(…)) copies Parquet field names verbatim — lowercase — and
Snowflake stores them as quoted identifiers. Unquoted SQL folds to uppercase, so
every query failed with invalid identifier 'POSTEAM'. The load itself succeeded,
because COPY INTO used MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE, so the break
only surfaced at read time. Fix: build the template explicitly with
OBJECT_CONSTRUCT('COLUMN_NAME', UPPER(COLUMN_NAME), …) and keep column order with
WITHIN GROUP (ORDER BY ORDER_ID). Quoting every identifier in the queries also
works, but it pushes the problem onto every future query instead of fixing it once.
CREATE TABLE IF NOT EXISTS hides schema changes. Fixing the template did
nothing until the tables were dropped — hence --recreate. An idempotent DDL
statement is not the same as a migration.
Snowflake has no role-level statement timeout. ALTER ROLE … SET STATEMENT_TIMEOUT_IN_SECONDS is not valid; the parameter lives on account,
warehouse, user, or session. The ceiling belongs on GAMEDAY_WH, which every
gateway query runs through anyway.
A tool's default filter is part of its contract. The tools originally had no
season_type filter, so "2024" quietly meant regular season plus playoffs —
inflating team play counts by ~10% and reordering the QB leaderboard. Defaulting to
REG and making POST/ALL explicit removed a whole class of wrong answers.
A cost guardrail and a bulk load are the same knob. The 60s
STATEMENT_TIMEOUT_IN_SECONDS on GAMEDAY_WH that protects the gateway also killed the
1.3M-row pbp COPY. Raising it per-session does not work: Snowflake enforces the lower
of the session and warehouse values, so the warehouse ceiling wins (verified — a session
set to 1800 still died at 60s). The fix is to keep statements short rather than raise the
limit: load anything over 100 MB as one COPY per season shard. The guardrail stays at
60s and the load still completes.
"The file exists" is not a checkpoint. The first full run reported schedules at 570
rows — it had silently reused a two-season parquet left by an earlier run. A cached
artifact is only trustworthy if this pipeline recorded producing it, so the cache test
is now "file exists AND the checkpoint has a pulled_at", not path.exists().
Check the upstream's shape before batching it. participation has no season column,
so splitting a multi-season pull by season silently packed whole batches into one shard;
ff_opportunity types season as a string, so an == 2006 filter raised rather than
returning empty. Both were invisible until the per-season shard counts were compared
against the seasons requested.
UPPER()-ing column names can collide. The fix for lowercase quoted identifiers has
a failure mode of its own: cfbfastR's pbp contains 40 column pairs differing only by
case (EPA/epa, TFL/tfl) because the upstream renamed columns between eras, so
the uppercase template generated duplicate identifiers and CREATE TABLE failed with
"Object already exists". Verified across all 40 pairs that no row ever has both
variants non-null — they're the same measure from different seasons — so the combine
step now coalesces case-variants into one column, losslessly.
An API answers exactly what you asked, and no more. CFBD /plays takes year, week
and seasonType as parameters and returns rows containing none of them. Load those rows
as-is and week attribution is gone forever. Request parameters are data — stamp them
onto the rows at fetch time. (Recoverable here without re-spending 89 calls only
because the shard filenames encoded season/type/week.)
A column's name is not its contents. nflverse's players.gsis_id holds an ESB
id for 6,093 of 25,035 players — every one of them a player the league never issued
a GSIS id to, and in every case gsis_id == esb_id. Nothing errors: the crosswalk
happily carries ABB498348 as a GSIS id, and every join to pbp then returns zero
rows and reports that as a finding. A silent zero is worse than a crash, because
it looks like an answer. The fix is to type values by their shape at the single
point every source row enters (retype_id), not to remember the quirk at each join.
A read-only role is not a read-only session. role=GAMEDAY_READER on a
Snowflake connection sets only the session's PRIMARY role. Snowflake defaults
users to DEFAULT_SECONDARY_ROLES = ALL, so the session silently also holds every
other role the user has — on this account GAMEDAY_LOADER, ACCOUNTADMIN and
ORGADMIN. current_role() still answers GAMEDAY_READER, and SHOW GRANTS TO ROLE GAMEDAY_READER still shows SELECT and nothing else, so both places you would
look to check say it is fine. It was found by trying the write rather than
reading the grants: the reader could INSERT, DELETE and DROP in MARTS. The
gateway now issues USE SECONDARY ROLES NONE on connect, and
sql/setup_readonly_hardening.sql gives the endpoint its own single-role user.
Verify a posture by attempting the thing it forbids; a grant listing is a claim,
not a test.
The id you need is the one field they left out. ESPN's injuries payload
carries each player's name, position and team — and no athlete id. The id exists
only inside the link URLs (/player/_/id/4240824/joey-blount). Without it every
row is unjoinable to the crosswalk, so a snapshot that looked complete would have
been decorative: names alone cannot identify a player, which is the entire
premise of MART_PLAYER_XWALK. It is now parsed out of the href — a genuinely
brittle dependency on a URL format, so the snapshot measures its own id coverage
every run and warns below 95% rather than silently warehousing junk.
Validate the curated table against something that did not write it. No free
source has NFL stadium coordinates, so data/reference/nfl_stadiums.csv is
hand-entered — 62 rows of exactly the kind of data that quietly contains a
transposed digit. It is checked against two independent signals: distance to an
independently geocoded city, and Open-Meteo's terrain elevation at that exact
point (Denver must come back near 1,600 m). The first run flagged two stadiums;
both were the geocoder being wrong — an unqualified "Santa Clara" resolves to
Cuba and "Carson" to Carson City, Nevada. So the validator now asks for several
candidates and takes the nearest. A check that cannot distinguish its own
failures from the data's is not a check.
A good correlation can hide a useless variable. The reconstructed game-site
weather validates at r = 0.973 on temperature and r = 0.680 on wind, with a bias
of under 1 mph — numbers that read as "wind is fine." It is not. Of the 636 games
nflverse calls 15+ mph, the reconstruction flags 219: 34% recall, and 21% at
the 20 mph threshold. The error is concentrated exactly where the question lives,
because a reanalysis model averages wind over an ~11 km grid cell and smooths away
the local extremes, while nflverse reports a point observation at the stadium.
Aggregate fit said yes; the only cut anyone actually asks about said no. So the
rule is: prefer nflverse's observed wind wherever it exists, never threshold or
rank on the reconstruction, and say which one an answer used
(docs/verification/weather-eval.md).
Rank on the number, not on the number you print. get_qb_epa_leaders ordered
by ROUND(AVG(epa), 3) — the display column — so among QBs who round to the same
three decimals the order was whatever Snowflake returned for equal sort keys.
2021 has Stafford, Mahomes and Brady all at 0.185. It was usually right, which
is the worst failure mode available: the leaderboard was not reproducible between
identical calls and nothing downstream could detect it. Rank on the full-precision
value, break ties explicitly, and when the lead is smaller than the printed
precision, say "edged" and caveat it rather than crowning a winner.
Confidence in an identity is not confidence about which identity you meant.
player_profile ordered candidates by MATCH_CONFIDENCE and answered "Josh
Allen" with a centre who last played in 2016 — his ids were cleaner than the
Bills quarterback's, so he scored higher. The crosswalk was right; both men
exist. The lesson is that a score answering "is this row one person?" says
nothing about "is this the person asked for", and using it that way is a category
error that reads as a data bug.
Check whether you already own the data before you buy it. The market work
started by wiring up The Odds API. Its free tier gates historical odds behind a
paid plan, so a backtesting archive looked impossible — until a look at
RAW.SCHEDULES showed nflverse already ships the closing spread and total for
all 7,276 games back to 1999, under CC-BY, redistributable. So market history
needed no paid tier and no new vendor, and the paid API is now only responsible
for current lines. The convention was verified before use rather than assumed:
league-wide the data yields 49.0% home covers ex-pushes and 48.7% overs, which is
where an efficient market has to sit, and is the check that the sign is not
inverted.
A licence can constrain a tool's shape, not just its docs. The Odds API
permits analytical use and forbids "offering our data through your own API...
intended to serve as a source of raw data for others". Fourth&Data is a public
API, so a tool returning book-by-book prices would be the prohibited thing no
matter what the docstring said. market_lines therefore returns a cross-book
consensus and a disagreement measure and never a per-book table — enforced by a
test that fails if a bookmaker name appears in the envelope at all.
The image is the deployment, not the repo. standings and live_scores
imported ingest/espn.py, which needs polars and the Snowflake loader — neither
of which the gateway image installs, and the Dockerfile copies only server/.
In production that raised ImportError, the tools' own try/except turned it into a
polite "the upstream is unreachable", and the feature would have been permanently
dead while blaming ESPN. Everything worked locally, where ingest/ is on the
path. The runtime now has a standard-library-only client, and a test fails the
build if anything under server/ imports the ingest stack again. A failure
that disguises itself as someone else's outage is worth a test.
Free tiers have shape, not just size. The CFBD free tier's binding constraint isn't the 1,000 calls — a full 2015–2025 pull of recruiting, portal and returning production costs 38. It's that play-by-play is gated to a paid tier entirely, which is why the ingest is bulk-first and the API is a fallback rather than the default.
Name the metric you actually compute. get_qb_epa_leaders filters
play_type = 'pass', which includes sacks but excludes scrambles and designed runs.
Calling that "EPA per play" in the docstring would have had the model confidently
report a passing-efficiency stat as total QB value.
MARTS layer (dbt + one python mart)
transform/ is a dbt project: stg_pbp_nfl staging plus 16 marts — the
fourth-down stack (go conversion, FG make, punt nets, three WP surfaces, coach
behavior), player marts (mart_qb_season with opponent adjustment,
mart_player_usage with late-season splits and age), team marts
(mart_team_identity with PROE, mart_team_def_season), history marts
(mart_game_team comeback flags, mart_player_game_extremes all-time
percentiles), and the college side (mart_cfb_qb_season_early,
mart_cfb_team_season, mart_cfb_comps_features with recruiting + draft
outcomes). Full build: ~12s, well under the 60s statement ceiling.
One mart is python-built on purpose: MART_CFB_QB_SEASON (2015–2025) reuses
the PDR Phase-1 playText parser for API-era QB attribution
(transform/python_marts/build_mart_cfb_qb_season.py) — validated code the
SQL layer can't replicate.
Roadmap
dbt: RAW → MARTS models with tests
CFBD ingest (college) + cross-league draft-class join
Fourth&Data launch toolset (14 tools, envelope voice, sanity CI)
Streamable HTTP transport + API keys + audit log table
Deploy (Fly.io) as public MCP endpoint
Scheduled market capture (GitHub Actions, 52 credits/month)
Public site (
site/, static, no build step)Entity resolution: player + team crosswalk marts (Group C)
Live layer: ESPN + Sleeper clients, stadium reference, weather (Group A)
The Odds API (held: awaiting key + ToS review)
Capability eval + the cheap wins it ranked (situational, bio, injuries, conditions, standings, live)
Market layer: odds ingest (quota-gated), ATS history, consensus lines
Deploy; re-run the eval in September (see ops/queued_jobs.md)
Deferred: rest/travel + officials (the eval ranks them near-zero demand)
GitHub Actions scheduled ingest
Custom domain (fourthanddata.com) on the Fly endpoint
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseBqualityDmaintenanceAn MCP server that bridges AI assistants with data warehouses through Cube.js to enable governed, natural language semantic analytics queries. It provides tools for metadata discovery and secure query execution while enforcing governance policies like PII blocking and access limits.3751MIT
- AlicenseNot gradedqualityAmaintenanceAn MCP server that provides access to over 12 years of NFL play-by-play data through a local DuckDB database. It enables users to query player performance, team statistics, and situational efficiency metrics like EPA and WPA using natural language.13MIT
- AlicenseAqualityAmaintenanceA Snowflake MCP server — SQL queries, schema exploration, and data insights for AI assistants62MIT
- AlicenseAqualityCmaintenanceSecure MCP server for safe, read-only DB access by AI agents, with SQL guardrails, table allowlists, PII masking, and audit logs6347MIT
Related MCP Connectors
Read-only MCP server for wafergraph.com's semiconductor & AI supply-chain data: 30 tools, no auth.
Official Microsoft MCP Server to query Microsoft Entra data using natural language
MCP server connecting AI agents to non-custodial staking data across 130+ networks.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/xceleraterecruiting-dotcom/gameday-gateway'
If you have feedback or need assistance with the MCP directory API, please join our Discord server