cito-mcp
v0.5.8
Published
Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).
Maintainers
Readme
cito-mcp
Standalone MCP server for the Cito esports API — curated outcome tools for agents building esports apps, dashboards, bots, and research flows.
Version: 0.4.9 · Node: >=20 · Install: npx cito-mcp
Primary games & sports: lol · cs2 · dota2 · cod · ufc · tennis, plus RAF wrestling, BKFC bare knuckle and the non-UFC MMA archives (Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, and mma across all of them) through the combat_* tools. Long-tail REST stays available via call_api.
Philosophy: outcomes, not endpoints
v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
Ships 42 curated outcome tools that answer jobs instead of mirroring REST:
| Job | Tool |
| --- | --- |
| What’s live right now? | live_matches |
| What’s coming up? | upcoming_schedule |
| Build a match card / recap | match_summary |
| Deep timeline / live state | match_details |
| Team page | team_profile |
| Player / fighter profile | player_profile |
| Player form & win streak | player_form |
| Table / rankings | standings |
| Ranking climbers & droppers | rankings_movers |
| Pre-match briefing (with surface) | match_preview |
| Event / fight-night card | event_card |
| Rivalry record | head_to_head |
| Tennis tournament catalog | tournaments |
| One day of tennis | tennis_schedule |
| Tennis player match log | player_matches |
| Tennis career statistics | player_stats |
| Tennis ranking trajectory | player_rankings_history |
| Fighter / team photos | event_card · player_profile |
| Which raw REST route exists? | list_routes |
| Name → ID | resolve_entity / search_entities |
Composites multi-fetch server-side, return a stable JSON envelope, and isolate partial failures in partial[] so one bad secondary section does not fail the whole page.
MCP is design-time / agent assistance. Ship production backends with Cito REST + the user’s API key. Do not put multi-tenant traffic through this process.
Honest gaps, in-band
Where the source data is partial, the tool says so instead of shipping a plausible-looking wrong value:
| Situation | What the tool returns |
| --- | --- |
| The two ranking lists compared by rankings_movers are not a week apart | gapDays + comparisonWindow ("12-week gap"), comparisonsAreWeekly: false, and a warning naming both dates |
| A tennis tournament draw is missing matches | draw.shortRounds (["R128 45/64"]), draw.missingRounds, draw.complete |
| A stats section the endpoint does not publish | player_stats.unavailable names the section and why, rather than a block of nulls |
| search_entities results that do not match at all | dropped, counted under data.relevance.droppedBelowFloor, with a warning naming them |
Install
One command (recommended)
npx cito-mcp install --key cito_your_key_hereDetects the MCP clients installed on your machine — Claude Code, Claude Desktop, Cursor, Windsurf, Codex CLI — and writes the config for each. Then restart your editor.
It is safe to re-run: existing config is merged, not replaced, your other MCP servers are left alone, a .cito-bak backup is written before the first change, and running it again just rotates the key rather than adding a second entry.
npx cito-mcp install --dry-run # show the plan, write nothing
npx cito-mcp install --key … --client cursor # one client only
npx cito-mcp install --helpGet a key at citoapi.com/dashboard.
Manual — Claude Code
claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" npx -y cito-mcpQuote the --. PowerShell 5.1 strips a bare -- before the CLI sees it; because -e takes a variable number of values it then swallows npx -y cito-mcp as env vars and fails with unknown option '-y'. The quoted form works in PowerShell, cmd, bash and zsh alike. npx cito-mcp install avoids the problem entirely.
From a local clone (development):
cd mcp
npm install
npm run build
claude mcp add cito -e CITO_API_KEY=cito_your_key_here "--" node "%CD%\dist\index.js"Cursor
Add to Cursor MCP settings (e.g. .cursor/mcp.json or Cursor Settings → MCP):
{
"mcpServers": {
"cito": {
"command": "npx",
"args": ["cito-mcp"],
"env": {
"CITO_API_KEY": "cito_your_key_here"
}
}
}
}Windows local clone:
{
"mcpServers": {
"cito": {
"command": "node",
"args": ["C:/Users/Owner/Projects/cito-api-scraper-windows/mcp/dist/index.js"],
"env": {
"CITO_API_KEY": "cito_your_key_here"
}
}
}
}Windsurf / other MCP hosts
Same shape as Cursor: command + args + env.CITO_API_KEY. Prefer npx cito-mcp so clients pick up published patches without a local install.
The server exits immediately if CITO_API_KEY is missing. The key is sent only as the x-api-key header and is never logged.
Environment
| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| CITO_API_KEY | yes | — | Cito API key → x-api-key on every upstream call |
| CITO_API_BASE | no | https://api.citoapi.com/api/v1 | Override base URL (staging / self-hosted) |
Preferred tool order
- Unsure →
list_capabilities(optionalapi_healthfor tier / included games) - Name without ID →
resolve_entity(chain) orsearch_entities(browse) - Live / upcoming →
live_matches/upcoming_schedule - Match card →
match_summary(thenmatch_detailsif needed) - Team / player pages →
team_profile/player_profile - Tables / rivalry / preview / event card →
standings/head_to_head/match_preview/event_card - Escape hatch →
list_routesto find a path, thencall_api(allowlisted paths only)
Never invent IDs — resolve them or take them from live / schedule / search results.
Mnemonic: resolve → live/schedule → summary → deep.
Tool catalog (16)
All tools are read-only. Names are snake_case with no cito_ prefix (the server name already brands the surface).
| Tool | Outcome | When to use | When not to use | Key inputs |
| --- | --- | --- | --- | --- |
| list_capabilities | Catalog of tools, games, jobs, recipes (no network) | Session start; map UI screens to tools; filter by game / job | You already know the tool and have IDs | game?, job?, q?, includeExamples?, includeRecipes? |
| api_health | Reachability, key validity, tier, rate limits, optional game probes | Once per session; after 401/403/RATE_LIMIT; entitlement checks | Fetching live scores or entity data | includeGameProbes? |
| resolve_entity | Name → best typed id/slug (+ ranked candidates) | User named “T1”, “s1mple”, “IEM Cologne” without an ID; chaining into profiles | Already have a stable id/slug; large browse lists | q, game?, type?, limit? |
| search_entities | Paginated entity browse/search for one game | Typeahead / pickers; “list teams matching…” | Single-name resolve for chaining; full profile pages | game, q?, type?, limit?, cursor?, activeOnly? |
| live_matches | Live board (one game or all); CS2 uses /cs2/live; includes UFC | “What’s live?”; ops/dashboard strip | Upcoming fixtures; historical recaps | game? (all or omit), limitPerGame?, includeLabelsOnly? |
| upcoming_schedule | Upcoming fixtures/events with filters | “What’s on this week?”; team next matches | Live-only boards; deep match recaps | game, hours?, from?, to?, team?, league?, tournamentId?, limit?, cursor? |
| match_summary | Composite match card: score, performances, media stubs | Default match UI / recap after a known matchId | No matchId yet; pure pre-match → match_preview; deep timelines | game, matchId, view?, includePlayerStats?, includeMedia? |
| match_details | Deep sections: timeline, live state, maps, advanced | Analyst dive; live in-game window; full demo list | First-pass live board; simple chat recaps | game, matchId, gameId?, sections?, includeTimeline?, includeLiveState?, includeAdvanced? |
| player_profile | Player/fighter + form/trends/radar when available | “How is X playing?”; player page scaffold | Full roster → team_profile; unresolved name | game, playerId or slug, recentLimit?, includeTrends?, view?, window?, role? |
| team_profile | Team/org + roster + recent form | Team page / “who is on this roster?” | UFC fighters → player_profile; unresolved name; pure tables | game, teamId or slug, recentLimit?, includeFormer?, view? |
| head_to_head | Composed H2H (no first-class REST H2H) | Rivalry / series record; preview context | Single-side form only; live scores; standings | game, sideA, sideB, entityType?, limit?, from?, to? |
| standings | League/event tables or world/division rankings | Playoff picture; UFC rankings; CDL / CS2 tables | Team form; live scores; match recaps | game, scope?, leagueId?, tournamentId?, eventId?, season?, stage?, division?, limit? |
| match_preview | Pre-match briefing: sides, rosters/form, H2H stub | Upcoming deep link; pick’ems; preview cards | Completed recaps; deep live state | game, matchId or (teamA + teamB), eventId?, includeH2H?, includeRosters?, recentLimit? |
| event_card | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings. Tennis returns the draw bracket with per-round completeness | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | game, eventIdOrSlug or q, includeBouts?, includeStandings?, limit?, drawMaxMatches? |
| list_routes | Index of raw REST routes from the live OpenAPI spec (method, path, summary, tag) | Finding a long-tail path before call_api; checking an endpoint exists | A curated tool covers the outcome | game?, q?, limit? |
| call_api | Allowlisted raw REST (data.raw) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | path, method?, queryJson?, bodyJson? |
Images
Fighter and team sides carry an images object wherever upstream supplies one —
on event_card bout corners and on player_profile. No extra call, no N+1.
"team1": {
"name": "Islam Makhachev",
"slug": "islam-makhachev",
"record": { "wins": 28, "losses": 1, "draws": 0, "text": "28-1-0 (W-L-D)" },
"championStatus": "champion",
"images": {
"headshotUrl": "https://…/MAKHACHEV_ISLAM_BELT_01-18.png",
"bodyImageUrl": "https://…/athlete_bio_full_body/…",
"imageUrl": "https://…/event_fight_card_upper_body/…",
"proxiedImageUrl": "https://api.citoapi.com/api/v1/public/images/ufc/aHR0cHM6…"
}
}Use proxiedImageUrl in a browser. the image host sends no CORS header and can
hotlink-block, so raw URLs render as broken images in a web UI. The proxied URL
is served by the API and is safe to put in an <img src>.
When a key is null the image does not exist for that entity; when the whole
images object is absent, upstream sent nothing for that side. The object is
never partially shaped — if any image exists, all four keys are present.
Tennis is the exception on purpose: the feed supplies a single portrait_url
(Wikimedia Commons) and no square crop, so that one URL fills both headshotUrl
and imageUrl, and proxiedImageUrl stays null because only fighter image URLs have
a proxy.
Tennis images carry their licence and credit — and a UI is expected to render them:
"images": {
"headshotUrl": "https://commons.wikimedia.org/wiki/Special:FilePath/Hoang_WMQ18_%2833%29_%2842647295495%29.jpg?width=400",
"license": "CC BY-SA 2.0",
"attribution": "si.robi",
"sourceUrl": "https://commons.wikimedia.org/wiki/File:Hoang_WMQ18_(33)_(42647295495).jpg"
}Commons portraits are mostly CC BY / CC BY-SA, which for commercial display
generally requires naming the author and stating the licence. attribution is
null for public-domain and CC0 images, which need no credit — that is a fact,
not missing data. Anything without a defensible licence has its URL removed
upstream, so a non-null headshotUrl always has a license beside it.
Games
| Game | Depth | Notes |
| --- | --- | --- |
| lol | deep | Team slugs; lol-match-*; no native global search |
| cs2 | deep | Prefixed ids; live path GET /cs2/live; trends / rankings |
| dota2 | thin | Numeric ids; radar; weak/no standings & roster endpoints |
| cod | medium | Org slugs; CDL standings; UUID match ids |
| ufc | medium | Fighter slugs; boutId; live + rankings. Includes Dana White's Contender Series and Road to UFC (2022-2026, all 19 cards with official round stats; event slugs like road-to-ufc-2-7, titles "Road to UFC 2.7") |
Combat leagues (combat_*, league argument)
RAF, BKFC and the MMA archives are not on the game enum. combat_events, combat_card, combat_bout, combat_athlete, combat_h2h and combat_rankings take league: "raf" | "bkfc" | "bellator" | "pride" | "strikeforce" | "wec" | "dream" | "elitexc" | "affliction" | "mma", and call_api reaches each of those prefixes. None of them is ever mixed into UFC answers.
| League | Scoring | Notes |
| --- | --- | --- |
| raf | per PERIOD (wrestling points, takedowns) | Athletes; champions per division |
| bkfc | per ROUND (punches landed/thrown, knockdowns) | Fighters; official rankings + champions |
| MMA archives: bellator, pride, strikeforce, wec, dream, elitexc, affliction, mma | per ROUND, official stats in UFC's shape ("13 of 18", controlTime "1:42", round 0 = whole fight; knockdowns, submission attempts, reversals) | Closed archives on one API router with identical routes: Bellator 131-172 (2014-2017), PRIDE 1997-2007 (PRIDE rules: 10-minute first round, then 5-minute rounds), Strikeforce 2006-2013, WEC 2001-2010, DREAM 2008-2012, EliteXC 2007-2008, Affliction 2008-2009. mma is every promotion at once: a fighter's whole non-UFC career, with recordByPromotion on combat_athlete, and recent / last across promotions; every bout and event carries its real promotion. Event slugs like bellator-142, pride-34, wec-48, strikeforce-barnett-vs-cormier; bout ids <promotion>-<fightId> (a bare fight id resolves in any archive). A fighter we also hold a UFC record for keeps the same slug as /ufc/fighters/{slug} (ufcFighterSlug). No live data, rankings or upcoming cards: combat_events when:"upcoming" returns [] with a note (default is recent), combat_card event:"last" is the most recent card (the default) and "next" is a non-retryable NOT_IMPLEMENTED, combat_rankings is a non-retryable NOT_IMPLEMENTED. All of it is older than the Free (30-day) / Starter (90-day) history window, so its stats are Pro: on those plans round stats come back as a HISTORY_WINDOW_EXCEEDED partial note while results still return. |
UFC projection notes (0.2.4)
Hardened agent-facing UFC shapes (offline-tested):
normalizeMatchreadsfighters[](corner+fighterName/profile.name), livered/blue, andboutId/dataId/ bareid. Never keeps label? vs ?when fighter names exist (including blue-first arrays).live_matchesusesextractLiveRows: preferliveBouts(empty array = honest empty board); never promote supervisorevents[]shells into match rows.- UFC empty board honesty: when
count === 0, section includesnote,emptyReason, slimhealth(workerAlive, lag), optional non-livesupervisorstrip andnextCard(armed only). Alsodata.note+meta.warningsfor single-game UFC. Do not treat empty as HTTP failure; do not invent? vs ?from event shells. upcoming_scheduleexpands event → bout rows with fighter labels; applies client-sidehours/from/to(API has nohours); event shells without bouts use the event name, not? vs ?.event_cardbouts share the samenormalizeMatchpath.standingsmaps division champions to rankC(rankText: "C", interimIC); contender#1stays numeric1— no dual numeric#1.
Fortnite: call_api only until promoted into the primary enum.
call_api allowlist
Path must start with / and match one of:
/health · /lol · /cs2 · /dota2 · /cod · /ufc · /fortnite · /tennis · /raf · /bkfc · /bellator · /pride · /strikeforce · /wec · /dream · /elitexc · /affliction · /mma · /openapi.json
Absolute URLs and .. traversal → PATH_NOT_ALLOWED. Prefer curated tools for all standard jobs.
Response envelope
Every tool returns one JSON object:
{
"ok": true,
"data": {},
"meta": {
"game": "cs2",
"source": "match_summary",
"fetchedAt": "2026-07-27T12:00:00.000Z",
"requestId": "mcp_…",
"tookMs": 120,
"upstreamCalls": 3,
"rateLimit": { "tier": "pro", "remaining": 54 }
},
"pagination": { "limit": 20, "hasMore": false, "nextCursor": null },
"partial": [],
"error": null
}| Condition | How to handle |
| --- | --- |
| ok: false | Tool-level failure; MCP isError: true; read error.code + error.recover[] |
| ok: true with partial[] | Use successful sections; do not treat as total failure |
| List tools | data.items + pagination (limit, hasMore, nextCursor, …) |
Error codes: VALIDATION · NOT_FOUND · UNSUPPORTED_GAME · UNAUTHORIZED · RATE_LIMIT · UPSTREAM · PATH_NOT_ALLOWED · NOT_IMPLEMENTED
Ambiguity: resolve_entity returns ok: true with data.needsDisambiguation + data.candidates — pick a candidate; do not wait for a hard error.
Retry only when error.retryable is true (typically RATE_LIMIT, UPSTREAM).
Pagination: default limit 20, max 50 (some tools clamp differently). Honor pagination.hasMore and pass pagination.nextCursor only — never invent cursors or reuse them after changing filters.
Builder recipes
Use these as agent playbooks when scaffolding apps. Prefer one composite per screen over N+1 thin GETs.
1. Live scoreboard
Goal: multi-game (or single-game) live strip with optional match drill-down.
1. api_health # optional — once per session
2. live_matches { game: "all" } # or "cs2" / "lol" / …
3. For a selected row:
match_summary { game, matchId } # default card
match_details { …, includeLiveState: true } # only if neededUI map
| Screen region | Source |
| --- | --- |
| Live rows (label, score, status) | live_matches → data.sections[].items |
| Expanded card | match_summary → data.scoreline, playerPerformances, media |
| In-game window | match_details sections liveState / timeline |
Do not: N+1 match_details for every live row; invent matchIds; use upcoming_schedule for live-only boards.
2. Player profile page
Goal: identity, team, recent form for a named player or fighter.
1. resolve_entity { q: "s1mple", game: "cs2", type: "player" }
# or type: "fighter" for UFC
2. player_profile {
game,
playerId | slug, # from resolve best/candidates
recentLimit: 10,
includeTrends: true,
view: "summary"
}
3. Optional: head_to_head for rivalry context (fighters/teams)UI map
| Screen region | Source |
| --- | --- |
| Header / identity | data.player |
| Current team | data.currentTeam |
| Recent form list | data.recentMatches + data.form |
| Career / radar | data.career, data.radar, data.seasonStats (when present) |
Do not: invent playerIds; use team_profile for full rosters; skip resolve when the user only gave a display name.
3. Event hub
Goal: schedule strip + standings/rankings + match previews for a league, tournament, or event.
1. list_capabilities { job: "schedule" } # optional orientation
2. upcoming_schedule {
game: "cs2", # or lol / cod / ufc / dota2
hours: 168, # or from/to ISO window
league | tournamentId | team # scope filters as available
}
3. standings {
game,
scope: "event" | "league" | "tournament" | "world" | "division",
eventId | leagueId | tournamentId | division | season
}
4. For a featured upcoming match:
match_preview { game, matchId } # or teamA + teamB
5. When a match goes live / completes:
match_summary { game, matchId }UI map
| Screen region | Source |
| --- | --- |
| Upcoming list | upcoming_schedule → data.items |
| Table / ranks | standings → data.rows |
| Featured match briefing | match_preview → sideA / sideB / h2h / talkingPoints |
| Live / completed card | match_summary |
Notes: Dota standings may return NOT_IMPLEMENTED or sparse data — fall back to schedule + profiles. UFC event hubs lean on fighter slugs, boutId, and standings with scope: "division".
Other quick recipes (from list_capabilities)
| Recipe id | Steps |
| --- | --- |
| live_ops_board | api_health → live_matches → match_summary / match_details |
| team_page | resolve_entity { type: "team" } → team_profile |
| match_center_completed | schedule/live for matchId → match_summary → optional match_details |
| app_scaffold | list_capabilities ∥ api_health → sample composites → ship REST in production |
Resources
| URI | Purpose |
| --- | --- |
| cito://capabilities | Static tool / game / workflow summary |
| cito://llms.txt | Live product agent context (may 404 upstream) |
| cito://openapi.json | Live public OpenAPI for typed clients — not for tool generation |
Transports
stdio is the default (Claude Code, Cursor, Windsurf).
Optional stateless Streamable HTTP:
CITO_API_KEY=cito_your_key_here node dist/index.js --http 8787
# → http://127.0.0.1:8787/mcpHosted (production): https://api.citoapi.com/mcp
Send the user's Cito key as x-api-key. Initialize and list_capabilities work without a key so Smithery can scan.
Migration from 0.1 (auto-generated tools)
Breaking change. 0.1.x loaded OpenAPI at boot and mass-generated ~100+ tools (generateAllTools), plus a couple of hard-coded helpers (e.g. cito_live_overview, cito_api_health). Agents selected among thin REST wrappers and often reimplemented composition client-side.
| 0.1 pattern | 0.2 replacement |
| --- | --- |
| Generated per-path tools (/cs2/matches/..., etc.) | Outcome tools in the table above |
| cito_live_overview / ad-hoc live probes | live_matches (fixed CS2 path /cs2/live; UFC included) |
| cito_api_health | api_health |
| OpenAPI-named tools with cito_ noise | 15 snake_case names, no cito_ prefix |
| Raw HTTP-shaped responses | Stable envelope: ok, data, meta, pagination, partial, error |
| Agent stitches roster + matches + H2H | team_profile, player_profile, head_to_head, match_preview |
| Fortnite / niche paths as first-class tools | call_api allowlist |
| Spec cache / boot network for tool list | Static catalog; list_capabilities is offline |
Action items for existing clients
- Drop any hardcoded 0.1 tool names; rebind prompts to the 15-tool catalog.
- Always parse the envelope (
ok/partial/error.recover) — do not assume bare REST JSON. - Use
resolve_entitybefore profile tools when you only have a display name. - Prefer composites over reintroducing N+1
call_apiloops. - Bump config to
npx cito-mcp(or rebuild from this package) and re-test live board + one profile + one match flow.
There is no compatibility shim for 0.1 tool names in 0.2.
Development
npm install
npm run build # tsc → dist/
npm test # node:test via tsx, offline (no network)
npm start # node dist/index.js (requires CITO_API_KEY)Source layout (authoritative tool registry: src/tools/index.ts):
src/
index.ts # MCP wiring, resources, stdio / --http
client.ts # fetchJson, auth, present, cursors
envelope.ts # success / error envelopes, MCP isError
instructions.ts # server instructions for agents
tools/
index.ts # allTools (15)
meta.ts # list_capabilities, api_health, call_api
resolve.ts # resolve_entity, search_entities
live.ts # live_matches, upcoming_schedule
match.ts # match_summary, match_details
player.ts # player_profile
team.ts # team_profile, head_to_head
standings.ts # standings
insight.ts # match_preview, event_card
normalize.ts # cross-game normalization
types.ts # ToolDef, schemasPublish notes
Package: [email protected]
| Item | Value |
| --- | --- |
| npm name | cito-mcp |
| bin | cito-mcp → dist/index.js |
| published files | dist/, README.md (package.json "files") |
| engines | node >= 20 |
| runtime dependency | @modelcontextprotocol/sdk only |
| prepublish | npm run prepublishOnly → npm run build |
Pre-publish checklist
npm test— offline suite green.npm run build— cleandist/.- Smoke against a real key (not in CI by default):
api_healthlive_matchesresolve_entity(e.g. T1 / s1mple)match_summarywith a realmatchIdfrom live/schedule
- Confirm
package.jsonversion and README match the shipped tool list. npm publishfrommcp/(or your release pipeline) with appropriate npm auth / access.prepublishOnlyruns build + tests + smoke, so a broken build cannot ship.
Install line for docs & marketing
npx cito-mcp install --key cito_…Manual fallback (note the quoted --, required for PowerShell):
claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp{ "command": "npx", "args": ["cito-mcp"], "env": { "CITO_API_KEY": "cito_…" } }Semver expectations
- 0.2.4 is a major surface break vs 0.1 (tool rename + removal of OpenAPI mass-generation).
- Further 0.2.x patches may refine envelopes and composite quality without renaming the shipped tools. (This line previously said "15 tools" while the catalog shipped 42 — the count here is intentionally not restated, because a number duplicated in prose drifts the moment the catalog changes.)
- Promoting Fortnite (or other titles) into the primary
gameenum would be a minor feature bump with catalog/docs updates.
Changelog (summary)
0.5.8
- Tennis
player_rankings_historyasks/tennis/rankings/history/{id}for one page (limit, newpage,sincesent asfrom) instead of downloading the whole career and slicing it; page 1 is the newestlimitrows, returned oldest-first as before.omittedOldercounts rows at the old end, newomittedNewerthe rows on earlier (newer) pages,hasMoreis the route's ownhas_next.
0.5.7
- Tennis
event_card:event.drawSizeanddraw.drawSizeare the API's stored draw size, the same value/tennis/tournaments/{id}serves (it is now corrected to the proven size: Paris 56, Shanghai 96).drawSizeVerifiedsays a proof stands behind it;draw.drawSizeDerivedis what the edition's own bracket shows.
0.5.6
- Tennis
event_carddraw is a real bracket (customer audit 2026-11-03).draw.completeis true only when every main round to the Final is played (it said true besidemissingRoundsF/SF/QF); every main round is listed withstatus(completed/in_progress/not_started/incomplete_archive),expected(byes excluded: a 56 draw's R64 holds 24),byes,walkovers, andpending(scheduled / live matches, never presented as results).drawSizeis the real field (Paris 56,drawSizeSource,bracketSize,byes);matchesExpectedcounts the main draw and is compared withmainDrawMatchesReturned(qualifying is reported apart). Each bout carrieswinnerId/loserId/outcome(a "6-3 6-7 RET" retirement says who won),startTime,nextMatchId(the bracket edge) andbracketPosition; rounds reportincluded/omittedwhendrawMaxMatchestrims them. - Tennis
head_to_headhonourslimit: it is sent to/tennis/h2h, which pages the meetings while every tally still covers all of them (meetingsReturned/meetingsTotal; the H2Hlevel_breakdownnow always sums tototal_matches). - Other tennis audit fixes in the same release: live stats as structured numerator / denominator / pct,
isSimulated/liveFeedon live rows,setNumberfromset_number, and the schedule / rankings / player readers updated for the API changes of 2026-11-03.
0.5.5
cs2_veto_sequencereturns the veto. It called the match head-to-head route and answered with the two teams' head-to-head record (cs2_team_h2h) instead of the veto (0.5.1 to 0.5.4). It now calls/cs2/matches/{matchId}/veto:vetoOrder(every ban, pick and the decider, in order),vetoStatus(complete,pending,forfeit_no_veto,unavailable),bestOfand the maps.cs2_opening_duelswithplayerIdworks. It called a route the API does not have (every call was a 404). A player's opening duels now come from the career totals:openingKills/openingDeaths(null when fewer than 90% of the player's maps record them), the recorded sums, coverage and success rate.cs2_team_map_statstakesopponentIdfor a map-by-map comparison of two teams (/cs2/teams/{team}/map-pool/vs/{opponent}: win rates, round difference, pick/ban/decider rates, their meetings per map, edge). Map rows carryinPool/poolStatus.cs2_round_economypoints to/cs2/maps/{mapId}/economyfor per-player money each round.
0.5.4
- Apex Legends through
call_api./apex/...paths (ALGS events, standings, scores) were refused withPATH_NOT_ALLOWEDalthough they are a live REST product. They are allowed now and listed inlist_capabilities. api_healthreports your real plan and limits: the tier plus the per-minute and monthly limit, used and remaining, read from the API's/health, instead ofnull.
0.5.3
- Paces itself and retries rate limits. One question fans out into several API calls, and on the Free plan (10 requests/minute on average, bursts to 30) those bursts came back as
429s the MCP never waited out. Requests are now capped at 4 in flight per API key (2 for a minute after a429), and once the API sendsX-RateLimit-Remaining-Minute: 0the next request waits for the minute window instead of being refused. Paid keys never receive those headers, so they never wait. 429 RATE_LIMIT_EXCEEDED(andTOO_MANY_CONCURRENT_REQUESTS) waitsRetry-After(at most 20 s per wait) and retries up to twice;502/503get one short retry. Each tool call has a 45 s wait budget shared by all its requests.MONTHLY_LIMIT_REACHEDand403are never retried.- When retries run out, the
RATE_LIMITenvelope says what happened (plan limit, retries already made,retryAfterMs) and carries the API's upgrade link.MONTHLY_LIMIT_REACHEDis nowretryable: falsewith the API's own message (reset date + upgrade), instead of a retryable error an agent kept hammering.
0.4.9
- Non-UFC MMA archives:
leaguebellator(2014-2017),pride(1997-2007),strikeforce(2006-2013),wec(2001-2010),dream(2008-2012),elitexc(2007-2008),affliction(2008-2009) andmma(all of them at once) on everycombat_*tool, and/bellator/pride/strikeforce/wec/dream/elitexc/affliction/mmaon thecall_api/list_routesallowlist. Official per-round stats in/ufc's shape; fighters with a UFC record share the UFC slug (combat_athletereturnsufcFighterSlug, and undermmathe whole non-UFC career split byrecordByPromotion);mmaeventsrecentandcombat_cardlastwork across promotions. Archive gaps are answered honestly: upcoming events are an empty list with a note,combat_carddefaults to the most recent card and refuses"next", andcombat_rankingsreturns a non-retryableNOT_IMPLEMENTED.HISTORY_WINDOW_EXCEEDEDsections are reported as a plan gap (Pro), not as a key problem. - Agent guidance (server instructions,
list_capabilities): the MMA archives are never mixed into UFC answers; UFC already includes Dana White's Contender Series and Road to UFC (2022-2026, all 19 cards with official round stats, slugs likeroad-to-ufc-2-7).
0.4.8
- RAF wrestling and BKFC bare knuckle: six league-parameterised
combat_*tools, and/raf+/bkfcon thecall_apiallowlist.
0.4.7
- Removed the unregistered tennis odds module and its README entries;
match_detailssections:["odds"]stays UFC-only and answersNOT_IMPLEMENTEDfor other games.
0.4.2
Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each reproduced from a raw payload and re-verified after the fix. Two were upstream (fixed on the API and verified with the 40/40 invariant gate); three were MCP-side.
- Tennis
standingspublished a fake total and could not be paged./tennis/standings?top_n=5setrank_max=top_n, so the count (which carried the rank bounds) equalled the page size:total:5,hasMore:falsefor a list of 150 ranked players. The API now counts the population without the rank window and returnsranked_players/total_pages/has_next,/standingstakes apageargument, and the tool reportstotal/rankedPlayers/totalPages/hasMore— page 2 of the ATP list now starts at rank 6. The sameallRows.lengthmistake in thepaginationblock was fixed too, so the two counts cannot disagree. event_cardpublished the requested id as the event name. With a bad id it answeredok:trueandevent.name = "atp_2026_999999"over a 404 thatpartial[]reported correctly. Names now come from the payload or arenull, withevent.resolvedrecording whether the detail fetch succeeded.leaderboard_*reported the page size as the population. Every board returned"total": len(items)after applying.limit(limit), solimit=2answeredtotal:2— for boards whose own docs record thousands of qualifiers. Upstream now carriescount(*) OVER ()(Postgres evaluates it afterGROUP BY/HAVING, so it is exactly the qualifying population, at no extra round trip): aces 2488, break-conversion 1089 (matching the figure in its own docstring), 1st-serve-won 1141, bp-saved 1186, return-games 987, tiebreak 364, deciders 334, comebacks 611, finals 66. Each tool also reportsreturnedand the board'sminimumAttempts.resolve_entity { q: "Nole" }answered "M Canoles". The upstream name search puts Novak Djokovic first forNole— it knows a nickname nothing in the payload carries, sorankScorescores it 0 — and the tool floored every unmatched row to 1 and then sorted, letting a substring collision win.pushCandidatesnow lets the upstream's own first row stand as the alias answer (labelledmeta.matchedBy: "upstream-first") when our scorer is silent, andsearch_entitiesexempts exactly that one row from its relevance floor. Padding is still dropped:Sinnerstill returns only Sinner./tennis/tournaments/calendarsilently ignored?month=. FastAPI drops unknown query parameters, so?month=9and?month=3returned the identical 621-event season list and a caller asking "what is on in September" got the whole year. The route now takesmonthand filters on the event's start month (2026-09 → 12 events).standingsandleaderboardswere never cached, so every call re-ran its grouped scan and paid db1's ~90ms RTT per query: 474–922ms on every request while every cached route answered in <20ms. That held eleven tools permanently over the 400ms latency budget. Both prefixes now have TTLs (1800s / 600s); repeat calls measure 4–5ms.bestOfnow readsbest_of, the only spelling the tennis route emits.
0.4.1
Tennis correctness pass, driven by the 2026-09-12 sweep (reports/tennis-mcp-sweep-2026-09-12.md). Every item was reproduced against live ids and re-verified after the fix.
match_summarytennis scoreline was always- : -./tennis/matches/{id}puts nosets_wonon its player objects, so the sets-won score was null for every completed match while the same payload held the real score inscoreandsets[]. Sets won are now derived (explicit →winner_sets_won→sets[]→scorestring),score.detailcarries the game score, and a set'scompletedis inferred when the archive omitsis_completed. The winner-orientedwinner_games/loser_gamespair is deliberately not used as a side score.bestOfnow readsbest_of.player_stats.bySurfacewas always four nulls. The endpoint publishes one object per surface and the projector coerced each to a number. Real per-surface blocks,servingStats, and an explicitunavailablemap for sections the endpoint does not publish.limitwas ignored byplayer_rankings_history(asked 4, got 342) and by/tennis/matches/completed. Both are bounded locally now, with*Returnedversus*Countso the day total is never confused with the page.tournaments level="WTA 1000"returned ATP Masters events. The API's level reverse-map is not tour-aware (_TIER_WTAmaps bothPMandMto "WTA 1000"), so the tool now sends and enforces the tour the level name states, and drops + counts contradicting rows. Fixed at the source too — the same widening also leakedWTA 125,Challenger,Davis CupandBillie Jean King Cup.rankings_moverscalled an 84-day gap "week-over-week".gapDays/comparisonWindow/comparisonsAreWeeklynow state the real interval, with a warning.standingsreports the tennisranking_dateasupdatedAtinstead ofnull.tennis_schedulenow counts the day's list/completed overlap, summarises statuses, and flags date-onlystartsAtplaceholders.live_matcheslabelled tennis rows by tournament, not players, because the live route'snameis the event.- Tennis players had no images although
/tennis/players/{id}carriesportrait_url. Now surfaced (plusiocas the side country on boards). search_entitieshad no relevance floor:q=Sinnerreturned eight unrelated names. Rows that match nothing are dropped and reported, and a one- or two-character fragment no longer counts as a match.
0.2.4
- UFC projection hardening (offline-tested):
normalizeMatch:fighters[]corners + profile, live red/blue,boutId/dataId; never keep? vs ?when names existextractRows/extractLiveRows: preferliveBouts(including empty); never fake match rows from supervisorevents[]upcoming_schedule: client-sidehours/from/to; bout expansion with fighter labels; event shells labeled by event nameevent_cardbouts share the same normalize pathstandings: champion rankC/ interimIC; no dual numeric#1for champ + contender
0.2.4
- Breaking: replaced OpenAPI mass-generated tools with 15 curated outcome tools
- Stable JSON envelope + MCP server instructions
- Fixed CS2 live path (
/cs2/live); UFC in live fan-out - Composites with
partial[]recovery;call_apipath allowlist - Resources:
cito://capabilities,cito://llms.txt,cito://openapi.json
See _workflow/CHANGELOG-0.2.md for the full 0.2 write-up.
