cito-mcp 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,19 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
42
42
 
43
43
  **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.
44
44
 
45
+ ### Honest gaps, in-band
46
+
47
+ Where the source data is partial, the tool says so instead of shipping a plausible-looking wrong value:
48
+
49
+ | Situation | What the tool returns |
50
+ | --- | --- |
51
+ | 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 |
52
+ | A tennis tournament draw is missing matches | `draw.shortRounds` (`["R128 45/64"]`), `draw.missingRounds`, `draw.complete` |
53
+ | A stats section the endpoint does not publish | `player_stats.unavailable` names the section and why, rather than a block of nulls |
54
+ | No bookmaker is quoting a match | `oddsAvailable: false` plus a note that it is a coverage gap, not an error |
55
+ | The odds feed leaves `match_id` null | `joinKey` (`{kind:"oddsEventId", …}`) and `playerIds`, so the row is still joinable |
56
+ | `search_entities` results that do not match at all | dropped, counted under `data.relevance.droppedBelowFloor`, with a warning naming them |
57
+
45
58
  ---
46
59
 
47
60
  ## Install
@@ -167,7 +180,7 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
167
180
  | `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?` |
168
181
  | `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?` |
169
182
  | `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?` |
170
- | `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
183
+ | `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?` |
171
184
  | `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?` |
172
185
  | `call_api` | Allowlisted raw REST (`data.raw`) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | `path`, `method?`, `queryJson?`, `bodyJson?` |
173
186
 
@@ -199,6 +212,11 @@ When a key is `null` the image does not exist for that entity; when the whole
199
212
  `images` object is absent, upstream sent nothing for that side. The object is
200
213
  never partially shaped — if any image exists, all four keys are present.
201
214
 
215
+ Tennis is the exception on purpose: the feed supplies a single `portrait_url`
216
+ (Wikimedia) and no square crop, so that one URL fills both `headshotUrl` and
217
+ `imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
218
+ a proxy. A card with the real photo slightly cropped beats a faceless card.
219
+
202
220
  ### Games
203
221
 
204
222
  | Game | Depth | Notes |
@@ -507,6 +525,35 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
507
525
 
508
526
  ## Changelog (summary)
509
527
 
528
+ ### 0.4.2
529
+
530
+ 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.
531
+
532
+ - **`tennis_odds` called a nonexistent match "no coverage".** `/tennis/odds/{id}` answers **HTTP 200 with `coverage.odds=false` for every id it does not know** — verified for `s365_2026_0000000` and for the literal `total-garbage-id`. So a typo and a genuine no-book gap were indistinguishable and both came back `ok:true`. `scope=match`/`live` now verify the id against `/tennis/matches/{id}` (which does 404) when no odds are quoted: unknown id → `NOT_FOUND` with recovery steps, real match → an honest coverage gap with `matchExists: true`.
533
+ - **Tennis `standings` published a fake total and could not be paged.** `/tennis/standings?top_n=5` set `rank_max=top_n`, so the count (which carried the rank bounds) equalled the page size: `total:5`, `hasMore:false` for a list of **150** ranked players. The API now counts the population without the rank window and returns `ranked_players`/`total_pages`/`has_next`, `/standings` takes a `page` argument, and the tool reports `total`/`rankedPlayers`/`totalPages`/`hasMore` — page 2 of the ATP list now starts at rank 6. The same `allRows.length` mistake in the `pagination` block was fixed too, so the two counts cannot disagree.
534
+ - **`event_card` published the requested id as the event name.** With a bad id it answered `ok:true` and `event.name = "atp_2026_999999"` over a 404 that `partial[]` reported correctly. Names now come from the payload or are `null`, with `event.resolved` recording whether the detail fetch succeeded.
535
+ - **`leaderboard_*` reported the page size as the population.** Every board returned `"total": len(items)` after applying `.limit(limit)`, so `limit=2` answered `total:2` — for boards whose own docs record thousands of qualifiers. Upstream now carries `count(*) OVER ()` (Postgres evaluates it after `GROUP 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 reports `returned` and the board's `minimumAttempts`.
536
+ - **`resolve_entity { q: "Nole" }` answered "M Canoles".** The upstream name search puts Novak Djokovic first for `Nole` — it knows a nickname nothing in the payload carries, so `rankScore` scores it 0 — and the tool floored every unmatched row to 1 and then *sorted*, letting a substring collision win. `pushCandidates` now lets the upstream's own first row stand as the alias answer (labelled `meta.matchedBy: "upstream-first"`) when our scorer is silent, and `search_entities` exempts exactly that one row from its relevance floor. Padding is still dropped: `Sinner` still returns only Sinner.
537
+ - **`/tennis/tournaments/calendar` silently ignored `?month=`.** FastAPI drops unknown query parameters, so `?month=9` and `?month=3` returned the identical 621-event season list and a caller asking "what is on in September" got the whole year. The route now takes `month` and filters on the event's start month (2026-09 → 12 events).
538
+ - **`standings` and `leaderboards` were 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**.
539
+ - **`bestOf`** now reads `best_of`, the only spelling the tennis route emits.
540
+
541
+ ### 0.4.1
542
+
543
+ 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.
544
+
545
+ - **`match_summary` tennis scoreline was always `- : -`.** `/tennis/matches/{id}` puts no `sets_won` on its player objects, so the sets-won score was null for every completed match while the same payload held the real score in `score` and `sets[]`. Sets won are now derived (explicit → `winner_sets_won` → `sets[]` → `score` string), `score.detail` carries the game score, and a set's `completed` is inferred when the archive omits `is_completed`. The winner-oriented `winner_games`/`loser_games` pair is deliberately not used as a side score. `bestOf` now reads `best_of`.
546
+ - **`match_details` denied tennis odds existed.** The `odds` section was gated to UFC; the same match returned FanDuel/Matchbook prices from `tennis_odds`. Both surfaces now share one projection (`summarizeTennisOdds`), and `playerStats` (previously always `null` for tennis) is filled from `/tennis/matches/{id}/stats`.
547
+ - **`player_stats.bySurface` was 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 explicit `unavailable` map for sections the endpoint does not publish.
548
+ - **`limit` was ignored** by `player_rankings_history` (asked 4, got 342) and by `/tennis/matches/completed`. Both are bounded locally now, with `*Returned` versus `*Count` so the day total is never confused with the page.
549
+ - **`tournaments level="WTA 1000"` returned ATP Masters events.** The API's level reverse-map is not tour-aware (`_TIER_WTA` maps both `PM` and `M` to "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 leaked `WTA 125`, `Challenger`, `Davis Cup` and `Billie Jean King Cup`.
550
+ - **`rankings_movers` called an 84-day gap "week-over-week".** `gapDays`/`comparisonWindow`/`comparisonsAreWeekly` now state the real interval, with a warning. `standings` reports the tennis `ranking_date` as `updatedAt` instead of `null`.
551
+ - **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
552
+ - **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
553
+ - **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
554
+ - **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
555
+ - **`search_entities` had no relevance floor**: `q=Sinner` returned eight unrelated names. Rows that match nothing are dropped and reported, and a one- or two-character fragment no longer counts as a match.
556
+
510
557
  ### 0.2.4
511
558
 
512
559
  - **UFC projection hardening** (offline-tested):
@@ -811,8 +811,14 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
811
811
  limit: limitSchema({
812
812
  default: 20,
813
813
  max: 50,
814
- description: 'Max bouts/matches on the card (default 20, max 50).',
814
+ description: 'Max bouts/matches on the card (default 20, max 50). For a tennis draw this bounds the flat `bouts` list; the bracket in `draw.rounds` is returned whole by default, because a truncated bracket is not a bracket. Use drawMaxMatches to bound it.',
815
815
  }),
816
+ drawMaxMatches: {
817
+ type: 'integer',
818
+ minimum: 1,
819
+ maximum: 500,
820
+ description: 'Tennis only. Cap the total matches carried in draw.rounds. The round list is filled from the business end backwards (Final, SF, QF, …) so a small cap keeps the decisive rounds and drops the early ones; draw.truncated and draw.omitted report what happened. Omit for the complete bracket.',
821
+ },
816
822
  },
817
823
  },
818
824
  handler: async (args, ctx) => {
@@ -835,6 +841,9 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
835
841
  const includeBouts = args.includeMatches !== false;
836
842
  const includeStandings = args.includeStandings === true;
837
843
  const limit = clampInt(args.limit, 20, 1, 50);
844
+ const drawMaxMatches = typeof args.drawMaxMatches === 'number' && Number.isFinite(args.drawMaxMatches)
845
+ ? Math.max(1, Math.min(500, Math.trunc(args.drawMaxMatches)))
846
+ : null;
838
847
  if (!eventKey && !q) {
839
848
  return errorEnvelope({
840
849
  code: 'VALIDATION',
@@ -873,10 +882,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
873
882
  });
874
883
  }
875
884
  }
885
+ /**
886
+ * Seeded with the key but explicitly NOT resolved.
887
+ *
888
+ * `name: q || eventKey` used to put the raw requested id in the name slot,
889
+ * and the tennis branch then kept it whenever its detail fetch failed
890
+ * (`pickString(d.name) ?? event.name`). event_card { eventIdOrSlug:
891
+ * "atp_2026_999999" } therefore answered ok:true with
892
+ * event.name = "atp_2026_999999" — an id presented as a tournament name,
893
+ * over a 404 that the partial[] block reported correctly. A name is
894
+ * something the API supplied or it is null; an identifier is not a name.
895
+ */
876
896
  let event = {
877
897
  id: eventKey,
878
898
  slug: eventKey,
879
- name: q || eventKey,
899
+ name: q || null,
900
+ resolved: false,
880
901
  game,
881
902
  };
882
903
  let bouts = [];
@@ -885,6 +906,33 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
885
906
  // upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
886
907
  // the tournament tree rather than one flat, order-dependent match list.
887
908
  let drawRounds = null;
909
+ /**
910
+ * Draw completeness. A bracket is only useful if the caller knows whether it
911
+ * is whole: atp_2026_560 returns R32 15/16, R64 31/32 and R128 45/64 (the
912
+ * archived matches it actually holds), and the Final round is absent until
913
+ * it is played. The old response gave no signal at all, so a partial
914
+ * bracket silently looked like a complete one and a missing round looked
915
+ * like a missing feature.
916
+ */
917
+ let drawSummary = null;
918
+ /** Matches a round of this code must contain, or null when unrecognised. */
919
+ const expectedMatchesForRound = (code, drawSize) => {
920
+ const c = (code ?? '').toUpperCase();
921
+ const rMatch = /^R(\d+)$/.exec(c);
922
+ if (rMatch)
923
+ return Math.max(1, Math.floor(Number(rMatch[1]) / 2));
924
+ if (c === 'F' || c === 'FINAL')
925
+ return 1;
926
+ if (c === 'SF')
927
+ return 2;
928
+ if (c === 'QF')
929
+ return 4;
930
+ if (c === 'BR' || c === 'RR')
931
+ return null; // round robin has no fixed size
932
+ if (drawSize && drawSize > 0 && c.startsWith('Q'))
933
+ return null;
934
+ return null;
935
+ };
888
936
  if (game === 'ufc') {
889
937
  const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
890
938
  upstreamCalls += 1;
@@ -1272,7 +1320,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1272
1320
  ...event,
1273
1321
  id: pickString(d.id) ?? eventKey,
1274
1322
  slug: pickString(d.id) ?? eventKey,
1275
- name: pickString(d.name) ?? event.name,
1323
+ // Never fall back to event.name here: that is the requested key, and
1324
+ // this branch only runs when the detail fetch SUCCEEDED, so the name
1325
+ // comes from the payload or it is honestly absent.
1326
+ name: pickString(d.name, d.id) ?? null,
1327
+ resolved: true,
1276
1328
  tour: pickString(d.tour) ?? null,
1277
1329
  level: pickString(d.level) ?? null,
1278
1330
  tier: pickString(d.tier) ?? null,
@@ -1298,12 +1350,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1298
1350
  if (draw.ok) {
1299
1351
  const dd = asRecord(unwrapPayload(draw.data)) ?? {};
1300
1352
  const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1353
+ const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
1301
1354
  const flat = [];
1355
+ // Fill the bracket from the front (the upstream orders rounds from the
1356
+ // business end backwards: SF, QF, R16, … R128), so a cap keeps the
1357
+ // decisive rounds and drops the early ones rather than the reverse.
1358
+ let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
1359
+ let omittedMatches = 0;
1302
1360
  drawRounds = rawRounds.map((rr) => {
1303
1361
  const rec = asRecord(rr) ?? {};
1304
1362
  const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
1305
1363
  const roundName = pickString(rec.round_name, rec.name, rec.round) ?? roundCode;
1306
- const matches = (Array.isArray(rec.matches) ? rec.matches : []).map((mm) => {
1364
+ const all = Array.isArray(rec.matches) ? rec.matches : [];
1365
+ const kept = all.slice(0, Math.max(0, budget));
1366
+ omittedMatches += all.length - kept.length;
1367
+ budget -= kept.length;
1368
+ const matches = kept.map((mm) => {
1307
1369
  const mr = asRecord(mm) ?? {};
1308
1370
  const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
1309
1371
  flat.push(row);
@@ -1318,11 +1380,56 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1318
1380
  nextMatchId: pickString(mr.next_match_id) ?? null,
1319
1381
  }, 'tennis');
1320
1382
  });
1321
- return { roundCode, roundName, matches };
1383
+ return {
1384
+ roundCode,
1385
+ roundName,
1386
+ matches,
1387
+ returned: all.length,
1388
+ expected: expectedMatchesForRound(roundCode, drawSize),
1389
+ complete: (() => {
1390
+ const expected = expectedMatchesForRound(roundCode, drawSize);
1391
+ return expected === null ? null : all.length >= expected;
1392
+ })(),
1393
+ };
1322
1394
  });
1323
1395
  bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
1324
1396
  if (!bouts.length)
1325
1397
  warnings.push('No draw yet: the tournament has no archived results');
1398
+ const expectedTotal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
1399
+ const returnedTotal = drawRounds.reduce((sum, r) => sum + r.returned, 0);
1400
+ const shortRounds = drawRounds
1401
+ .filter((r) => r.complete === false)
1402
+ .map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
1403
+ // A round of 128 draw must have a Final. Its absence is not an error
1404
+ // (the final may simply not be played yet) but it IS the difference
1405
+ // between "in progress" and "the API lost a round".
1406
+ const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
1407
+ const missingRounds = ['F', 'SF', 'QF']
1408
+ .filter((c) => !haveCodes.has(c))
1409
+ .filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
1410
+ drawSummary = {
1411
+ drawSize,
1412
+ // NOT `rounds`: that key holds the round ARRAY on the same object,
1413
+ // and spreading a count over it replaced the bracket with a number.
1414
+ roundCount: drawRounds.length,
1415
+ matchesReturned: returnedTotal,
1416
+ matchesExpected: expectedTotal || null,
1417
+ complete: expectedTotal > 0 ? returnedTotal >= expectedTotal : null,
1418
+ shortRounds,
1419
+ missingRounds,
1420
+ totalMatchesInCard: flat.length,
1421
+ returnedInBouts: bouts.length,
1422
+ truncated: omittedMatches > 0,
1423
+ omittedMatches,
1424
+ maxMatches: drawMaxMatches,
1425
+ note: 'matchesReturned counts what the archive holds for this tournament; matchesExpected counts a full draw of drawSize. shortRounds names every round that is short, and missingRounds names a round the bracket should contain but does not.',
1426
+ };
1427
+ if (shortRounds.length) {
1428
+ warnings.push(`Draw is partial: ${shortRounds.join(', ')}. The archive does not hold every match of this tournament yet.`);
1429
+ }
1430
+ if (omittedMatches > 0) {
1431
+ warnings.push(`drawMaxMatches=${drawMaxMatches} dropped ${omittedMatches} early-round match(es) from draw.rounds; raise or omit it for the complete bracket.`);
1432
+ }
1326
1433
  }
1327
1434
  else {
1328
1435
  partial.push(partialFromRejection('bouts', {
@@ -1355,7 +1462,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1355
1462
  message: `dota tournament HTTP ${detail.status}`,
1356
1463
  httpStatus: detail.status,
1357
1464
  }));
1358
- event = { id: eventKey, slug: eventKey, name: q || eventKey, game };
1465
+ event = { id: eventKey, slug: eventKey, name: q || null, resolved: false, game };
1359
1466
  }
1360
1467
  if (includeBouts) {
1361
1468
  const matchesRes = await fetchJson(ctx, '/dota2/matches/upcoming', { query: { limit } });
@@ -1390,6 +1497,18 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1390
1497
  }));
1391
1498
  }
1392
1499
  }
1500
+ /**
1501
+ * Last line of defence against an identifier posing as a name.
1502
+ *
1503
+ * Every game branch seeds `event.name` from `q`/`eventKey` so a partially
1504
+ * resolved card still has something in the slot. Any branch that failed and
1505
+ * left that seed in place would publish "atp_2026_999999" as a tournament
1506
+ * name, and nothing downstream can tell an id from a name. It is nulled
1507
+ * here; `resolved` records that the detail fetch never succeeded.
1508
+ */
1509
+ if (event.resolved !== true && typeof event.name === 'string' && event.name === eventKey) {
1510
+ event = { ...event, name: null, nameResolved: false };
1511
+ }
1393
1512
  return successEnvelope({
1394
1513
  source: 'event_card',
1395
1514
  game,
@@ -1414,7 +1533,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1414
1533
  // (Q1..R128, QF, SF, F) and each match's score/bracket progression —
1415
1534
  // this is what makes a bracket renderable; `bouts` above is the flat
1416
1535
  // list every other game already gets.
1417
- ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds } : null } : {}),
1536
+ ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds, ...(drawSummary ?? {}) } : null } : {}),
1418
1537
  standings: includeStandings ? standingsSnippet : null,
1419
1538
  nextSteps: [
1420
1539
  'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
@@ -156,13 +156,23 @@ Parallel-safe: yes. Upstream cost: 1.`,
156
156
  const root = asRecord(res.data);
157
157
  const body = asRecord(root?.data) ?? root ?? {};
158
158
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeAceRow);
159
+ /**
160
+ * `total` is the QUALIFYING POPULATION, not the page.
161
+ *
162
+ * The API used to return len(items) AFTER applying .limit(limit), so limit=2
163
+ * answered total:2 for a board with thousands of eligible players and the
164
+ * caller had no way to tell a page from the whole board. It now carries
165
+ * count(*) OVER (), the count of players passing the board's own threshold.
166
+ * `returned` and `hasMore` are stated separately so the two can never be
167
+ * confused again, and the threshold is surfaced so the number is explicable.
168
+ */
159
169
  const total = typeof body.total === 'number' ? body.total : items.length;
160
170
  return successEnvelope({
161
171
  pagination: {
162
172
  limit,
163
173
  offset: 0,
164
174
  total,
165
- hasMore: false,
175
+ hasMore: items.length < total,
166
176
  nextCursor: null,
167
177
  prevCursor: null,
168
178
  },
@@ -179,6 +189,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
179
189
  stat: pickString(body.stat) ?? 'aces',
180
190
  items,
181
191
  total,
192
+ returned: items.length,
193
+ ...(typeof body.minimum_attempts === 'number' ? { minimumAttempts: body.minimum_attempts } : {}),
182
194
  },
183
195
  });
184
196
  },
@@ -306,13 +318,15 @@ Parallel-safe: yes. Upstream cost: 1.`,
306
318
  const root = asRecord(res.data);
307
319
  const body = asRecord(root?.data) ?? root ?? {};
308
320
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeRow);
321
+ // Same contract as leaderboard_aces: total is the qualifying population,
322
+ // not the page. See the note there.
309
323
  const total = typeof body.total === 'number' ? body.total : items.length;
310
324
  return successEnvelope({
311
325
  pagination: {
312
326
  limit,
313
327
  offset: 0,
314
328
  total,
315
- hasMore: false,
329
+ hasMore: items.length < total,
316
330
  nextCursor: null,
317
331
  prevCursor: null,
318
332
  },
@@ -329,6 +343,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
329
343
  stat: pickString(body.stat) ?? spec.stat,
330
344
  items,
331
345
  total,
346
+ returned: items.length,
347
+ ...(typeof body.minimum_attempts === 'number' ? { minimumAttempts: body.minimum_attempts } : {}),
332
348
  },
333
349
  });
334
350
  },
@@ -3,7 +3,8 @@
3
3
  */
4
4
  import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch, normalizeUfcMethod, presentSides } from './normalize.js';
6
+ import { normalizeMatch, normalizeUfcMethod, presentSides, tennisSetCompleted } from './normalize.js';
7
+ import { summarizeTennisOdds } from './odds.js';
7
8
  import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
8
9
  async function getSection(ctx, path, query) {
9
10
  return fetchJson(ctx, path, { query });
@@ -20,7 +21,7 @@ export function parseBestOf(r) {
20
21
  const n = Number(v);
21
22
  return Number.isFinite(n) && [1, 3, 5, 7, 9].includes(n) ? n : null;
22
23
  };
23
- const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo);
24
+ const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo) ?? fromNumber(r.best_of);
24
25
  if (direct)
25
26
  return direct;
26
27
  const strat = r.strategy;
@@ -61,6 +62,12 @@ function matchCore(game, matchId, raw) {
61
62
  const scoreline = m.team1 || m.team2
62
63
  ? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
63
64
  : null;
65
+ /**
66
+ * Tennis: `score` is the set-by-set game score ("4-6 6-3 6-3 7-5"), which is
67
+ * a DIFFERENT fact from the sets-won scoreline. "3 : 1" is the result; it
68
+ * cannot distinguish 6-0 6-0 from three tiebreaks. Both are carried.
69
+ */
70
+ const scoreDetail = game === 'tennis' ? (pickString(r.score, r.score_raw) ?? null) : null;
64
71
  const core = {
65
72
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
66
73
  game,
@@ -73,6 +80,7 @@ function matchCore(game, matchId, raw) {
73
80
  score: {
74
81
  team1: s1,
75
82
  team2: s2,
83
+ ...(scoreDetail ? { detail: scoreDetail } : {}),
76
84
  },
77
85
  team1: m.team1,
78
86
  team2: m.team2,
@@ -385,7 +393,10 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
385
393
  player1Games: p1g,
386
394
  player2Games: p2g,
387
395
  tiebreak: sr.tiebreak ?? null,
388
- completed: sr.is_completed === true,
396
+ // is_completed is a LIVE-route field. The archive route omits it,
397
+ // so every set of a finished match read completed:false. Fall
398
+ // back to the games, which always decide whether a set is over.
399
+ completed: tennisSetCompleted(sr),
389
400
  };
390
401
  });
391
402
  return;
@@ -420,6 +431,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
420
431
  display: `${match.team1?.name ?? '?'} ${match.score.team1 ?? '-'} : ${match.score.team2 ?? '-'} ${match.team2?.name ?? '?'}`,
421
432
  team1: match.score.team1,
422
433
  team2: match.score.team2,
434
+ ...(game === 'tennis' && match.score.detail ? { detail: match.score.detail } : {}),
423
435
  }, game);
424
436
  if (view === 'summary' && playerPerformances.length > 10) {
425
437
  playerPerformances = playerPerformances.slice(0, 10);
@@ -540,6 +552,52 @@ export function summarizeUfcOdds(data) {
540
552
  fullBookPath: '/ufc/bouts/{boutId}/odds',
541
553
  };
542
554
  }
555
+ /**
556
+ * Tennis per-match stats projection.
557
+ *
558
+ * `/tennis/matches/{id}/stats` does NOT return a row per player the way every
559
+ * other game's player-stats route does. It returns
560
+ * `{ stats: { winner: {...}, loser: {...} }, sets: [...], score }` — two objects
561
+ * keyed by OUTCOME. `match_details sections:["playerStats"]` had no tennis arm
562
+ * at all, so it answered `playerStats: null` while `match_summary` was filling
563
+ * `playerPerformances` from the same route.
564
+ *
565
+ * The sides stay keyed by outcome rather than being mislabelled as player1 /
566
+ * player2, because the payload carries no names and the ordering is not
567
+ * guaranteed to match the base row. `match.player1.is_winner` on the base
568
+ * section is what maps a name onto these numbers, and `sidesKeyedBy` says so.
569
+ */
570
+ export function summarizeTennisMatchStats(data) {
571
+ const root = asRecord(data) ?? {};
572
+ const payload = asRecord(root.data) ?? root;
573
+ const stats = asRecord(payload.stats);
574
+ const sets = Array.isArray(payload.sets) ? payload.sets : [];
575
+ const toNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
576
+ return {
577
+ matchId: pickString(payload.match_id) ?? null,
578
+ score: pickString(payload.score) ?? null,
579
+ sidesKeyedBy: 'outcome',
580
+ players: stats
581
+ ? {
582
+ winner: asRecord(stats.winner) ?? null,
583
+ loser: asRecord(stats.loser) ?? null,
584
+ }
585
+ : null,
586
+ sets: sets.map((s) => {
587
+ const sr = asRecord(s) ?? {};
588
+ return {
589
+ setNumber: toNum(sr.set_num) ?? toNum(sr.set_number),
590
+ winnerGames: toNum(sr.winner_games),
591
+ loserGames: toNum(sr.loser_games),
592
+ player1Games: toNum(sr.player1_games),
593
+ player2Games: toNum(sr.player2_games),
594
+ tiebreak: sr.tiebreak ?? null,
595
+ completed: tennisSetCompleted(sr),
596
+ };
597
+ }),
598
+ rawPath: '/tennis/matches/{matchId}/stats',
599
+ };
600
+ }
543
601
  export const matchDetails = {
544
602
  name: 'match_details',
545
603
  description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
@@ -574,7 +632,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
574
632
  type: 'string',
575
633
  enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
576
634
  },
577
- description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" (UFC) is opt-in: moneyline summarised per fighter with bookmaker count, best and median American price and implied probability, plus a count of every other market. Closing lines for a finished fight come back with currentlyOffered=false rather than being omitted.',
635
+ description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted); tennis returns the same bookmaker/market/outcome projection that tennis_odds serves, including oddsAvailable=false when no book is quoting. Odds exist for UFC and tennis only.',
578
636
  },
579
637
  includeTimeline: boolSchema('Include timeline section (heavy).', false),
580
638
  includeLiveState: boolSchema('Include live state/snapshots.', false),
@@ -726,6 +784,11 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
726
784
  load('playerStats', `/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
727
785
  if (game === 'ufc')
728
786
  load('playerStats', `/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
787
+ // Tennis has a real per-match stats route; the section simply had no arm
788
+ // for it and shipped playerStats:null forever.
789
+ if (game === 'tennis') {
790
+ loadWith('playerStats', `/tennis/matches/${encodeURIComponent(matchId)}/stats`, summarizeTennisMatchStats);
791
+ }
729
792
  }
730
793
  if (wanted.has('gamesOrMaps')) {
731
794
  if (game === 'lol')
@@ -772,10 +835,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
772
835
  if (game === 'ufc') {
773
836
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
774
837
  }
838
+ else if (game === 'tennis') {
839
+ // The gate that produced "Odds not curated for tennis; UFC only today"
840
+ // was written before /tennis/odds/{id} existed. It does: the same match
841
+ // returned FanDuel 1.105 / Matchbook 1.13 from tennis_odds while this
842
+ // section denied odds existed. Both surfaces now share one projection.
843
+ loadWith('odds', `/tennis/odds/${encodeURIComponent(matchId)}`, summarizeTennisOdds);
844
+ }
775
845
  else {
776
846
  partial.push(partialFromRejection('odds', {
777
847
  code: 'NOT_IMPLEMENTED',
778
- message: `Odds not curated for ${game}; UFC only today`,
848
+ message: `Odds are not curated for ${game}. Available: UFC (/ufc/bouts/{id}/odds) and tennis (/tennis/odds/{id}).`,
779
849
  }));
780
850
  }
781
851
  }
@@ -38,7 +38,7 @@ const TOOL_CATALOG = [
38
38
  },
39
39
  {
40
40
  name: 'search_entities',
41
- outcome: 'Browse/search entities with type filter and pagination',
41
+ outcome: 'Browse/search entities with type filter and pagination; when q is given, results below the relevance floor are dropped and reported under data.relevance',
42
42
  parallelSafe: true,
43
43
  games: [...PRIMARY_GAMES],
44
44
  jobs: ['team_page', 'player_form'],
@@ -138,7 +138,7 @@ const TOOL_CATALOG = [
138
138
  },
139
139
  {
140
140
  name: 'rankings_movers',
141
- outcome: 'Tennis ranking movers: climbers/fallers plus new entries and drop-outs',
141
+ outcome: 'Tennis ranking climbers/fallers plus new entries and drop-outs, with gapDays/comparisonWindow stating the real interval between the two lists',
142
142
  parallelSafe: true,
143
143
  games: ['tennis'],
144
144
  jobs: ['standings'],
@@ -168,7 +168,7 @@ const TOOL_CATALOG = [
168
168
  },
169
169
  {
170
170
  name: 'player_stats',
171
- outcome: 'Tennis career statistics: W/L, win%, titles, Grand Slam and Masters titles, breakdowns',
171
+ outcome: 'Tennis career statistics: W/L, win%, titles, per-surface breakdown, serving stats, and named gaps',
172
172
  parallelSafe: true,
173
173
  games: ['tennis'],
174
174
  jobs: ['player_form'],
@@ -188,7 +188,7 @@ const TOOL_CATALOG = [
188
188
  },
189
189
  {
190
190
  name: 'tennis_odds',
191
- outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match',
191
+ outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match; upcoming rows carry a joinKey and playerIds because the feed leaves match_id null',
192
192
  parallelSafe: true,
193
193
  games: ['tennis'],
194
194
  jobs: ['preview', 'match_page', 'odds'],
@@ -198,7 +198,7 @@ const TOOL_CATALOG = [
198
198
  },
199
199
  {
200
200
  name: 'tournaments',
201
- outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country',
201
+ outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country; a level name that names a tour also fixes it',
202
202
  parallelSafe: true,
203
203
  games: ['tennis'],
204
204
  jobs: ['preview', 'standings'],
@@ -208,7 +208,7 @@ const TOOL_CATALOG = [
208
208
  },
209
209
  {
210
210
  name: 'tennis_schedule',
211
- outcome: "One day of tennis: that day's schedule and/or completed matches",
211
+ outcome: "One day of tennis: that day's schedule and/or completed matches, with limit applied to each half and the two halves' overlap counted",
212
212
  parallelSafe: true,
213
213
  games: ['tennis'],
214
214
  jobs: ['schedule', 'match_page'],