cito-mcp 0.4.0 → 0.4.1

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,22 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
507
525
 
508
526
  ## Changelog (summary)
509
527
 
528
+ ### 0.4.1
529
+
530
+ 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.
531
+
532
+ - **`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`.
533
+ - **`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`.
534
+ - **`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.
535
+ - **`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.
536
+ - **`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`.
537
+ - **`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`.
538
+ - **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
539
+ - **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
540
+ - **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
541
+ - **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
542
+ - **`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.
543
+
510
544
  ### 0.2.4
511
545
 
512
546
  - **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',
@@ -885,6 +894,33 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
885
894
  // upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
886
895
  // the tournament tree rather than one flat, order-dependent match list.
887
896
  let drawRounds = null;
897
+ /**
898
+ * Draw completeness. A bracket is only useful if the caller knows whether it
899
+ * is whole: atp_2026_560 returns R32 15/16, R64 31/32 and R128 45/64 (the
900
+ * archived matches it actually holds), and the Final round is absent until
901
+ * it is played. The old response gave no signal at all, so a partial
902
+ * bracket silently looked like a complete one and a missing round looked
903
+ * like a missing feature.
904
+ */
905
+ let drawSummary = null;
906
+ /** Matches a round of this code must contain, or null when unrecognised. */
907
+ const expectedMatchesForRound = (code, drawSize) => {
908
+ const c = (code ?? '').toUpperCase();
909
+ const rMatch = /^R(\d+)$/.exec(c);
910
+ if (rMatch)
911
+ return Math.max(1, Math.floor(Number(rMatch[1]) / 2));
912
+ if (c === 'F' || c === 'FINAL')
913
+ return 1;
914
+ if (c === 'SF')
915
+ return 2;
916
+ if (c === 'QF')
917
+ return 4;
918
+ if (c === 'BR' || c === 'RR')
919
+ return null; // round robin has no fixed size
920
+ if (drawSize && drawSize > 0 && c.startsWith('Q'))
921
+ return null;
922
+ return null;
923
+ };
888
924
  if (game === 'ufc') {
889
925
  const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
890
926
  upstreamCalls += 1;
@@ -1298,12 +1334,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1298
1334
  if (draw.ok) {
1299
1335
  const dd = asRecord(unwrapPayload(draw.data)) ?? {};
1300
1336
  const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1337
+ const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
1301
1338
  const flat = [];
1339
+ // Fill the bracket from the front (the upstream orders rounds from the
1340
+ // business end backwards: SF, QF, R16, … R128), so a cap keeps the
1341
+ // decisive rounds and drops the early ones rather than the reverse.
1342
+ let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
1343
+ let omittedMatches = 0;
1302
1344
  drawRounds = rawRounds.map((rr) => {
1303
1345
  const rec = asRecord(rr) ?? {};
1304
1346
  const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
1305
1347
  const roundName = pickString(rec.round_name, rec.name, rec.round) ?? roundCode;
1306
- const matches = (Array.isArray(rec.matches) ? rec.matches : []).map((mm) => {
1348
+ const all = Array.isArray(rec.matches) ? rec.matches : [];
1349
+ const kept = all.slice(0, Math.max(0, budget));
1350
+ omittedMatches += all.length - kept.length;
1351
+ budget -= kept.length;
1352
+ const matches = kept.map((mm) => {
1307
1353
  const mr = asRecord(mm) ?? {};
1308
1354
  const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
1309
1355
  flat.push(row);
@@ -1318,11 +1364,56 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1318
1364
  nextMatchId: pickString(mr.next_match_id) ?? null,
1319
1365
  }, 'tennis');
1320
1366
  });
1321
- return { roundCode, roundName, matches };
1367
+ return {
1368
+ roundCode,
1369
+ roundName,
1370
+ matches,
1371
+ returned: all.length,
1372
+ expected: expectedMatchesForRound(roundCode, drawSize),
1373
+ complete: (() => {
1374
+ const expected = expectedMatchesForRound(roundCode, drawSize);
1375
+ return expected === null ? null : all.length >= expected;
1376
+ })(),
1377
+ };
1322
1378
  });
1323
1379
  bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
1324
1380
  if (!bouts.length)
1325
1381
  warnings.push('No draw yet: the tournament has no archived results');
1382
+ const expectedTotal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
1383
+ const returnedTotal = drawRounds.reduce((sum, r) => sum + r.returned, 0);
1384
+ const shortRounds = drawRounds
1385
+ .filter((r) => r.complete === false)
1386
+ .map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
1387
+ // A round of 128 draw must have a Final. Its absence is not an error
1388
+ // (the final may simply not be played yet) but it IS the difference
1389
+ // between "in progress" and "the API lost a round".
1390
+ const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
1391
+ const missingRounds = ['F', 'SF', 'QF']
1392
+ .filter((c) => !haveCodes.has(c))
1393
+ .filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
1394
+ drawSummary = {
1395
+ drawSize,
1396
+ // NOT `rounds`: that key holds the round ARRAY on the same object,
1397
+ // and spreading a count over it replaced the bracket with a number.
1398
+ roundCount: drawRounds.length,
1399
+ matchesReturned: returnedTotal,
1400
+ matchesExpected: expectedTotal || null,
1401
+ complete: expectedTotal > 0 ? returnedTotal >= expectedTotal : null,
1402
+ shortRounds,
1403
+ missingRounds,
1404
+ totalMatchesInCard: flat.length,
1405
+ returnedInBouts: bouts.length,
1406
+ truncated: omittedMatches > 0,
1407
+ omittedMatches,
1408
+ maxMatches: drawMaxMatches,
1409
+ 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.',
1410
+ };
1411
+ if (shortRounds.length) {
1412
+ warnings.push(`Draw is partial: ${shortRounds.join(', ')}. The archive does not hold every match of this tournament yet.`);
1413
+ }
1414
+ if (omittedMatches > 0) {
1415
+ warnings.push(`drawMaxMatches=${drawMaxMatches} dropped ${omittedMatches} early-round match(es) from draw.rounds; raise or omit it for the complete bracket.`);
1416
+ }
1326
1417
  }
1327
1418
  else {
1328
1419
  partial.push(partialFromRejection('bouts', {
@@ -1414,7 +1505,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1414
1505
  // (Q1..R128, QF, SF, F) and each match's score/bracket progression —
1415
1506
  // this is what makes a bracket renderable; `bouts` above is the flat
1416
1507
  // list every other game already gets.
1417
- ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds } : null } : {}),
1508
+ ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds, ...(drawSummary ?? {}) } : null } : {}),
1418
1509
  standings: includeStandings ? standingsSnippet : null,
1419
1510
  nextSteps: [
1420
1511
  'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
@@ -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'],