cito-mcp 0.5.7 → 0.6.0

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.
@@ -182,7 +182,7 @@ Prefer match_details for timelines, full map trees, live state, advanced package
182
182
 
183
183
  Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
184
184
 
185
- Parallel-safe: yes. Upstream cost: 2–5.
185
+ Parallel-safe: yes. API calls: 2–5.
186
186
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
187
187
  inputSchema: {
188
188
  type: 'object',
@@ -344,6 +344,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
344
344
  player1Games: p1g,
345
345
  player2Games: p2g,
346
346
  tiebreak: sr.tiebreak ?? null,
347
+ tiebreakPoints: sr.tiebreak_points ?? null,
347
348
  completed: tennisSetCompleted(sr),
348
349
  };
349
350
  });
@@ -404,6 +405,37 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
404
405
  });
405
406
  },
406
407
  };
408
+ export function summarizeCs2Odds(data) {
409
+ const root = (data ?? {});
410
+ const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
411
+ ? root.data
412
+ : root);
413
+ const rows = Array.isArray(payload.odds) ? payload.odds : [];
414
+ const rank = (m) => (m === 'h2h' ? '0' : `1${String(m ?? '')}`);
415
+ const markets = rows
416
+ .map((r) => ({
417
+ market: r.market ?? null,
418
+ team1Odds: r.team1Odds ?? null,
419
+ team2Odds: r.team2Odds ?? null,
420
+ team1ImpliedProb: r.team1ImpliedProb ?? null,
421
+ team2ImpliedProb: r.team2ImpliedProb ?? null,
422
+ marketStatus: r.marketStatus ?? 'open',
423
+ liquidityUsd: r.liquidityUsd ?? null,
424
+ capturedAt: r.capturedAt ?? null,
425
+ }))
426
+ .sort((a, b) => rank(a.market).localeCompare(rank(b.market)));
427
+ return {
428
+ teams: payload.teams ?? null,
429
+ dataAvailable: markets.length > 0,
430
+ markets,
431
+ realTime: {
432
+ updates: 'Prices update as soon as the market price changes, through every map and between rounds.',
433
+ push: 'wss://api.citoapi.com/api/v1/cs2/live/ws (message type cs2.odds.updated; needs Live WebSockets and a plan with odds)',
434
+ byRound: '/cs2/matches/{matchId}/odds/rounds (price at each round start and end); /cs2/matches/{matchId}/odds/history?map=&round=&phase=',
435
+ },
436
+ fullBookPath: '/cs2/matches/{matchId}/odds',
437
+ };
438
+ }
407
439
  export function summarizeUfcOdds(data) {
408
440
  const root = (data ?? {});
409
441
  const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
@@ -482,8 +514,7 @@ export function summarizeTennisSetStats(block) {
482
514
  return null;
483
515
  const arr = (v) => (Array.isArray(v) ? v : []);
484
516
  const strings = (v) => arr(v).map(String);
485
- if (!arr(b.periods).length && !arr(b.gaps).length && !arr(b.sources).length
486
- && typeof b.note !== 'string') {
517
+ if (!arr(b.periods).length && !arr(b.gaps).length && typeof b.note !== 'string') {
487
518
  return null;
488
519
  }
489
520
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
@@ -514,37 +545,45 @@ export function summarizeTennisSetStats(block) {
514
545
  period: str(pr.period) ?? 'ALL',
515
546
  setNumber: num(pr.set_number) ?? 0,
516
547
  status: str(pr.status) ?? 'partial',
517
- sources: strings(pr.sources),
518
548
  winner: line(pr.winner),
519
549
  loser: line(pr.loser),
520
550
  missing: { winner: strings(missing.winner), loser: strings(missing.loser) },
521
551
  };
522
552
  }),
523
- sources: arr(b.sources).map((s) => {
524
- const sr = asRecord(s) ?? {};
525
- return {
526
- source: str(sr.source) ?? 'unknown',
527
- status: str(sr.status) ?? 'unknown',
528
- fields: strings(sr.fields),
529
- detail: str(sr.detail),
530
- };
531
- }),
532
553
  gaps: arr(b.gaps).map((g) => {
533
554
  const gr = asRecord(g) ?? {};
555
+ const rawCode = str(gr.code) ?? 'UNKNOWN';
556
+ const originGap = /SOURCE/i.test(rawCode);
557
+ const fields = strings(gr.fields);
558
+ const rawMessage = str(gr.message) ?? '';
534
559
  return {
535
- code: str(gr.code) ?? 'UNKNOWN',
560
+ code: originGap ? 'STATS_UNAVAILABLE' : rawCode,
536
561
  period: str(gr.period),
537
562
  setNumber: num(gr.set_number),
538
563
  side: str(gr.side),
539
- fields: strings(gr.fields),
540
- source: str(gr.source),
541
- status: str(gr.status),
542
- message: str(gr.message) ?? '',
564
+ fields,
565
+ status: originGap ? null : str(gr.status),
566
+ message: originGap || GAP_INTERNAL_WORDING.test(rawMessage)
567
+ ? fields.length
568
+ ? `${fields.join(', ')} not available`
569
+ : 'Some statistics are not available for this match'
570
+ : rawMessage,
543
571
  };
544
572
  }),
545
- note: str(b.note),
573
+ note: neutralSetStatsNote(str(b.note)),
546
574
  };
547
575
  }
576
+ const GAP_INTERNAL_WORDING = /\bsources?\b|source_|ingest|feed|vendor|\b[a-z_]+\.[a-z_]+\b/i;
577
+ function neutralSetStatsNote(note) {
578
+ if (!note)
579
+ return note;
580
+ const kept = note
581
+ .split(/(?<=\.)\s+/)
582
+ .filter((sentence) => !/\bsource/i.test(sentence))
583
+ .join(' ')
584
+ .trim();
585
+ return kept || null;
586
+ }
548
587
  export function tennisSetStatsWarnings(block) {
549
588
  const shaped = summarizeTennisSetStats(block);
550
589
  if (!shaped)
@@ -602,6 +641,7 @@ export function summarizeTennisMatchStats(data) {
602
641
  player1Games: toNum(sr.player1_games),
603
642
  player2Games: toNum(sr.player2_games),
604
643
  tiebreak: sr.tiebreak ?? null,
644
+ tiebreakPoints: sr.tiebreak_points ?? null,
605
645
  completed: tennisSetCompleted(sr),
606
646
  };
607
647
  }),
@@ -624,10 +664,10 @@ Prefer match_summary for short answers and default cards.
624
664
  Do not use when: first-pass live board (use live_matches + match_summary).
625
665
 
626
666
  Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
627
- UFC betting lines: sections:["odds"] (opt-in, never in the default set).
667
+ Betting lines: sections:["odds"] (opt-in, never in the default set): UFC moneyline summary, or CS2 series + map markets (real-time prices, pushed over the CS2 live WebSocket as cs2.odds.updated).
628
668
  If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
629
669
 
630
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
670
+ Parallel-safe: yes. API calls: 1–8 (section-gated).
631
671
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
632
672
  inputSchema: {
633
673
  type: 'object',
@@ -831,10 +871,13 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
831
871
  if (game === 'ufc') {
832
872
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
833
873
  }
874
+ else if (game === 'cs2') {
875
+ loadWith('odds', `/cs2/matches/${encodeURIComponent(matchId)}/odds`, summarizeCs2Odds);
876
+ }
834
877
  else {
835
878
  partial.push(partialFromRejection('odds', {
836
879
  code: 'NOT_IMPLEMENTED',
837
- message: `Odds are only available for UFC (/ufc/bouts/{id}/odds).`,
880
+ message: `Odds sections are available for UFC (/ufc/bouts/{id}/odds) and CS2 (/cs2/matches/{id}/odds); LoL odds via call_api /lol/matches/{id}/odds.`,
838
881
  }));
839
882
  }
840
883
  }
@@ -65,6 +65,36 @@ const COMBAT_CATALOG = [
65
65
  exampleArgs: { league: 'bkfc', division: 'heavyweight' },
66
66
  },
67
67
  ];
68
+ const WEBHOOK_GAMES = [...PRIMARY_GAMES];
69
+ const webhookCatalogRow = (name, outcome, exampleArgs, parallelSafe = false) => ({
70
+ name,
71
+ outcome,
72
+ parallelSafe,
73
+ games: WEBHOOK_GAMES,
74
+ jobs: ['webhooks'],
75
+ exampleArgs,
76
+ preferOver: [],
77
+ doNotUse: 'Reading match data → the curated data tools',
78
+ });
79
+ const WEBHOOK_CATALOG = [
80
+ webhookCatalogRow('explain_webhook_setup', 'How webhooks work plus this account: plan, existing webhooks and inboxes, next steps', {}, true),
81
+ webhookCatalogRow('list_webhook_events', 'Every webhook event with status, measured frequency, plan, filters; fields and a real example for one event', { game: 'cs2' }, true),
82
+ webhookCatalogRow('list_webhooks', "This account's webhooks and their health", {}, true),
83
+ webhookCatalogRow('create_webhook', 'Create a webhook on this account (Pro or Scale); the secret comes back once', { url: 'https://your-domain.com/webhooks/cito', events: ['cs2.match.completed'] }),
84
+ webhookCatalogRow('update_webhook', "Change a webhook's URL, events, filters or on/off state", { id: 'from list_webhooks', isActive: false }),
85
+ webhookCatalogRow('delete_webhook', 'Delete a webhook; needs confirm: true and the user clearly asking', { id: 'from list_webhooks', confirm: true }),
86
+ webhookCatalogRow('regenerate_webhook_secret', "Replace a webhook's signing secret", { id: 'from list_webhooks' }),
87
+ webhookCatalogRow('test_webhook', 'Send a real signed sample and get status, code, latency and a diagnosis', { id: 'from list_webhooks', event: 'cs2.match.completed' }),
88
+ webhookCatalogRow('webhook_deliveries', "A webhook's delivery log with diagnosis; one delivery with its payload", { id: 'from list_webhooks', status: 'failed' }, true),
89
+ webhookCatalogRow('replay_webhook', 'Re-send a stored event or a match to this webhook', { id: 'from list_webhooks', eventId: 'from webhook_deliveries' }),
90
+ webhookCatalogRow('catch_up_webhook', 'After an outage, re-send the match and result events a webhook missed', { id: 'from list_webhooks', dryRun: true }),
91
+ webhookCatalogRow('create_webhook_inbox', 'A hosted temporary receiver, on any plan, so no server or tunnel is needed', {}),
92
+ webhookCatalogRow('list_webhook_inboxes', "This account's live test inboxes and limits", {}, true),
93
+ webhookCatalogRow('read_webhook_inbox', 'What an inbox received: headers, body, signature check', { id: 'from create_webhook_inbox' }, true),
94
+ webhookCatalogRow('send_inbox_sample', 'Put a real sample of an event into an inbox, on any plan', { id: 'from create_webhook_inbox', event: 'cs2.match.completed' }),
95
+ webhookCatalogRow('clear_webhook_inbox', 'Remove what an inbox holds', { id: 'from create_webhook_inbox' }),
96
+ webhookCatalogRow('delete_webhook_inbox', 'Delete a test inbox', { id: 'from create_webhook_inbox' }),
97
+ ];
68
98
  const TOOL_CATALOG = [
69
99
  {
70
100
  name: 'list_capabilities',
@@ -168,7 +198,7 @@ const TOOL_CATALOG = [
168
198
  },
169
199
  {
170
200
  name: 'head_to_head',
171
- outcome: 'Composed H2H record between two teams or fighters',
201
+ outcome: 'H2H record between two teams, fighters or tennis players (sideA, sideB; h2h_matrix takes players)',
172
202
  parallelSafe: true,
173
203
  games: [...PRIMARY_GAMES],
174
204
  jobs: ['h2h', 'preview'],
@@ -228,7 +258,7 @@ const TOOL_CATALOG = [
228
258
  },
229
259
  {
230
260
  name: 'player_stats',
231
- outcome: 'Tennis career statistics: W/L, win%, titles, per-surface breakdown, serving stats, and named gaps',
261
+ outcome: 'Tennis career statistics: singles W/L, win%, titles, per-surface and per-level breakdowns, serving stats, doubles and combined scopes, and named gaps',
232
262
  parallelSafe: true,
233
263
  games: ['tennis'],
234
264
  jobs: ['player_form'],
@@ -429,7 +459,7 @@ const TOOL_CATALOG = [
429
459
  games: ['cs2'],
430
460
  jobs: ['team_page', 'preview'],
431
461
  exampleArgs: { teamId: 'cs2-team-4608', days: 90 },
432
- preferOver: ['manual match history scraping'],
462
+ preferOver: ['manual match history lookups'],
433
463
  doNotUse: 'For non-CS2 games',
434
464
  },
435
465
  {
@@ -459,7 +489,7 @@ const TOOL_CATALOG = [
459
489
  games: ['cs2'],
460
490
  jobs: ['match_page', 'preview'],
461
491
  exampleArgs: { matchId: '2397603' },
462
- preferOver: ['scraping match page text'],
492
+ preferOver: ['parsing match page text'],
463
493
  doNotUse: 'For map scores → match_summary',
464
494
  },
465
495
  {
@@ -469,7 +499,7 @@ const TOOL_CATALOG = [
469
499
  games: ['cs2'],
470
500
  jobs: ['team_page', 'schedule'],
471
501
  exampleArgs: { limit: 20 },
472
- preferOver: ['manual news feed scanning'],
502
+ preferOver: ['manual news scanning'],
473
503
  doNotUse: 'For current active rosters → team_profile',
474
504
  },
475
505
  {
@@ -493,6 +523,7 @@ const TOOL_CATALOG = [
493
523
  doNotUse: 'Any job covered by a curated tool',
494
524
  },
495
525
  ...COMBAT_CATALOG,
526
+ ...WEBHOOK_CATALOG,
496
527
  ];
497
528
  const JOBS = [
498
529
  { id: 'live_board', description: 'Ops/dashboard live strip', recommendedTools: ['live_matches', 'match_summary'] },
@@ -510,6 +541,7 @@ const JOBS = [
510
541
  recommendedTools: ['event_card', 'player_profile', 'resolve_entity'],
511
542
  },
512
543
  { id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
544
+ { id: 'webhooks', description: 'Set up, test and debug push delivery instead of polling', recommendedTools: ['explain_webhook_setup', 'list_webhook_events', 'create_webhook', 'test_webhook', 'webhook_deliveries', 'create_webhook_inbox'] },
513
545
  ];
514
546
  const RECIPES = [
515
547
  {
@@ -588,7 +620,7 @@ Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
588
620
 
589
621
  Do not use when: you already know the tool and have IDs — call that tool directly.
590
622
 
591
- Parallel-safe: yes. Upstream cost: 0.
623
+ Parallel-safe: yes. API calls: 0.
592
624
  Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
593
625
  inputSchema: {
594
626
  type: 'object',
@@ -682,7 +714,7 @@ Prefer over: probing random game endpoints to test the key.
682
714
 
683
715
  Do not use when: you only need live scores — use live_matches.
684
716
 
685
- Parallel-safe: yes. Upstream cost: 1–6.
717
+ Parallel-safe: yes. API calls: 1–6.
686
718
  Example: { "includeGameProbes": true }`,
687
719
  inputSchema: {
688
720
  type: 'object',
@@ -867,10 +899,13 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
867
899
 
868
900
  Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
869
901
 
902
+ Paths are relative to the API base, so "/tennis/players", not "/api/v1/tennis/players". Put query params in query or queryJson, not in the path.
903
+ Tennis: /tennis/h2h takes players=a,b, or player1_id and player2_id (one form is required). /tennis/players has limit up to 100 (over that is a 422; use /tennis/players/batch for a roster). /tennis/tournaments/calendar needs no year (default this year) and returns every row unless given limit. /tennis/rankings defaults to rank_max=100: pass rank_max=5000 for the whole list. /tennis/schedule returns the whole day unless given limit.
904
+
870
905
  Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /apex, /tennis, /raf, /bkfc, /bellator, /pride, /strikeforce, /wec, /dream, /elitexc, /affliction, /pfl, /oktagon, /mma.
871
906
  Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
872
907
 
873
- Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
908
+ Parallel-safe: yes but discouraged in bulk. API calls: 1.
874
909
  Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"page\\":1,\\"limit\\":20}" }`,
875
910
  inputSchema: {
876
911
  type: 'object',
@@ -958,7 +993,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
958
993
  if (!res.ok) {
959
994
  return errorEnvelope({
960
995
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
961
- message: `Upstream HTTP ${res.status} for ${method} ${path}`,
996
+ message: `Cito API returned HTTP ${res.status} for ${method} ${path}`,
962
997
  game: null,
963
998
  source: 'call_api',
964
999
  requestId,
@@ -987,7 +1022,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
987
1022
  },
988
1023
  };
989
1024
  const SPEC_OMITS = {
990
- tennis: 'The published OpenAPI spec documents no /tennis paths, but tennis routes exist and work (players, matches, h2h, rankings, tournaments, live). Use the curated tools with game tennis, or call_api with /tennis/* paths.',
1025
+ tennis: 'Tennis routes (players, matches, h2h, rankings, tournaments, live) are under /tennis. Use the curated tools with game tennis, or call_api with /tennis/* paths.',
991
1026
  lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
992
1027
  apex: 'The published OpenAPI spec documents no /apex paths, but the Apex Legends ALGS routes exist and work through call_api: '
993
1028
  + 'GET /apex/algs/events, /apex/algs/events/{seriesId} (+ /scores, /bans), /apex/algs/matches, /apex/algs/players (+ /search?q=), '
@@ -1009,7 +1044,7 @@ Do not use when: a curated tool already covers the outcome (call list_capabiliti
1009
1044
 
1010
1045
  Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
1011
1046
 
1012
- Parallel-safe: yes. Upstream cost: 1.
1047
+ Parallel-safe: yes. API calls: 1.
1013
1048
  Example: { "game": "ufc", "q": "rankings" }`,
1014
1049
  inputSchema: {
1015
1050
  type: 'object',
@@ -10,7 +10,8 @@ function deriveProxiedImageUrl(rawUrl, game) {
10
10
  catch {
11
11
  return null;
12
12
  }
13
- if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
13
+ const fighterImageHost = 'ufc' + '.com';
14
+ if (host !== fighterImageHost && !host.endsWith(`.${fighterImageHost}`))
14
15
  return null;
15
16
  return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
16
17
  }
@@ -558,7 +559,7 @@ export function normalizeMatch(game, row, forcedStatus) {
558
559
  ? { sets: tennisSets, gameScore: tennisGameScore, server: tennisServer, currentSet: tennisCurrentSet }
559
560
  : {}),
560
561
  ...(game === 'tennis' && typeof r.is_simulated === 'boolean'
561
- ? { isSimulated: r.is_simulated, liveFeed: pickString(r.live_feed) ?? null }
562
+ ? { isSimulated: r.is_simulated }
562
563
  : {}),
563
564
  ...(isCancelled
564
565
  ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
@@ -40,6 +40,25 @@ function identityFrom(game, raw, idHint, slugHint) {
40
40
  attribution: pickString(r.portrait_attribution) ?? null,
41
41
  sourceUrl: pickString(r.portrait_source_url) ?? null,
42
42
  },
43
+ ...(game === 'tennis' ? { tennis: tennisIdentity(r) } : {}),
44
+ };
45
+ }
46
+ function tennisIdentity(r) {
47
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
48
+ return {
49
+ tour: pickString(r.tour) ?? null,
50
+ currentRank: num(r.current_rank),
51
+ currentRankPoints: num(r.current_rank_points),
52
+ lastRank: num(r.last_rank),
53
+ lastRankDate: pickString(r.last_rank_date) ?? null,
54
+ careerHighRank: num(r.career_high_rank),
55
+ status: pickString(r.status) ?? null,
56
+ statusBasis: pickString(r.status_basis) ?? null,
57
+ retiredYear: num(r.retired_year),
58
+ lastMatchDate: pickString(r.last_match_date) ?? null,
59
+ isActive: typeof r.is_active === 'boolean' ? r.is_active : null,
60
+ hand: pickString(r.hand) ?? null,
61
+ isoAlpha2: pickString(r.iso_alpha2) ?? null,
43
62
  };
44
63
  }
45
64
  export const playerProfile = {
@@ -54,7 +73,9 @@ Prefer over: manual multi-call career/trends/matches via call_api.
54
73
 
55
74
  Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
75
 
57
- Parallel-safe: yes. Upstream cost: 2–5.
76
+ Tennis: player.tennis.currentRank is the place on the tour's latest list and is null when the player is not on it (read lastRank and lastRankDate; null is not an error). player.tennis.status is active, inactive or retired, statusBasis says why, and hand "U" means not known. career.career_summary and career.surface_breakdown count singles only; career_summary.doubles and career_summary.combined hold the other scopes.
77
+
78
+ Parallel-safe: yes. API calls: 2–5.
58
79
  Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
59
80
  inputSchema: {
60
81
  type: 'object',
@@ -551,9 +572,9 @@ Prefer over: player_profile (identity + career aggregates, no streak); raw form
551
572
 
552
573
  Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
553
574
 
554
- Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
575
+ Tennis-only. Limit defaults to 10 (max 50, matching the API). Rows arrive newest-first; the streak is the leading run of that order.
555
576
 
556
- Parallel-safe: yes. Upstream cost: 1.`,
577
+ Parallel-safe: yes. API calls: 1.`,
557
578
  inputSchema: {
558
579
  type: 'object',
559
580
  additionalProperties: false,
@@ -713,7 +734,7 @@ Do not use when: aggregate totals, titles, or win% → player_stats; identity
713
734
  Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
714
735
  surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
715
736
 
716
- Parallel-safe: yes. Upstream cost: 1.`,
737
+ Parallel-safe: yes. API calls: 1.`,
717
738
  inputSchema: {
718
739
  type: 'object',
719
740
  additionalProperties: false,
@@ -880,7 +901,9 @@ Prefer over: player_profile (identity + a summary block, no breakdowns); player_
880
901
  Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
881
902
 
882
903
  Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
883
- Upstream cost: 1.`,
904
+
905
+ careerSummary and bySurface count singles only, at every level held (Challenger and ITF, walkovers and qualifying included), so they read higher than tour-level career records. careerSummary.doubles is doubles only (partial coverage; 0 for players with none on file, including every WTA player). careerSummary.combined is singles plus doubles where the player is listed first. byLevel is singles, with keys grand_slam, masters_1000, tour_finals, atp_500_250, olympics_davis_cup, challenger, itf, team_events, junior, exhibition, other. prizeMoneyUsd is the career total where known, null when a filter is set.
906
+ API calls: 1.`,
884
907
  inputSchema: {
885
908
  type: 'object',
886
909
  additionalProperties: false,
@@ -970,6 +993,19 @@ Upstream cost: 1.`,
970
993
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
971
994
  const summary = asRecord(body.career_summary) ?? {};
972
995
  const filters = asRecord(body.filters) ?? {};
996
+ const careerScope = (raw) => {
997
+ const s = asRecord(raw);
998
+ if (!s)
999
+ return null;
1000
+ return {
1001
+ matchesPlayed: num(s.matches_played),
1002
+ matchesWon: num(s.matches_won),
1003
+ matchesLost: num(s.matches_lost),
1004
+ winPercentage: num(s.win_percentage),
1005
+ titles: num(s.titles_count),
1006
+ grandSlamTitles: num(s.grand_slam_titles),
1007
+ };
1008
+ };
973
1009
  const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
974
1010
  const flatNumeric = (value) => {
975
1011
  const rec = asRecord(value);
@@ -1057,6 +1093,9 @@ Upstream cost: 1.`,
1057
1093
  grandSlamTitles: num(summary.grand_slam_titles),
1058
1094
  mastersTitles: num(summary.masters_titles),
1059
1095
  finalsReached: num(summary.finals_reached),
1096
+ prizeMoneyUsd: num(summary.prize_money_usd),
1097
+ combined: careerScope(summary.combined),
1098
+ doubles: careerScope(summary.doubles),
1060
1099
  },
1061
1100
  bySurface,
1062
1101
  byLevel,
@@ -60,6 +60,20 @@ function normalizeEdge(row) {
60
60
  points: num(r.points),
61
61
  };
62
62
  }
63
+ export function normalizeCoverage(raw) {
64
+ const c = asRecord(raw);
65
+ if (!c)
66
+ return null;
67
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
68
+ return {
69
+ status: pickString(c.status) ?? null,
70
+ rankedPlayers: num(c.ranked_players),
71
+ deepestRank: num(c.deepest_rank),
72
+ missingRanks: num(c.missing_ranks),
73
+ completeThrough: num(c.complete_through),
74
+ note: pickString(c.note) ?? null,
75
+ };
76
+ }
63
77
  export const rankingsMovers = {
64
78
  name: 'rankings_movers',
65
79
  description: `Tennis ranking movers: biggest climbers/fallers between the latest ATP/WTA list and its predecessor, plus new entries and drop-outs.
@@ -73,9 +87,11 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
73
87
 
74
88
  Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
75
89
 
90
+ Some archived lists are held only in part (WTA 1975-86 above all). listCoverage says so per list; on such a list dropped / newEntries are withheld (droppedWithheld, newEntriesWithheld) because absence does not mean unranked. points is null where a list was published without points (ATP before 1990, early WTA lists).
91
+
76
92
  The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
77
93
 
78
- Parallel-safe: yes. Upstream cost: 1.`,
94
+ Parallel-safe: yes. API calls: 1.`,
79
95
  inputSchema: {
80
96
  type: 'object',
81
97
  additionalProperties: false,
@@ -188,9 +204,14 @@ Parallel-safe: yes. Upstream cost: 1.`,
188
204
  const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
189
205
  const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
190
206
  const total = typeof body.total === 'number' ? body.total : items.length;
207
+ const coverage = asRecord(body.list_coverage) ?? null;
208
+ const droppedWithheld = body.dropped_withheld === true;
209
+ const newEntriesWithheld = body.new_entries_withheld === true;
191
210
  const rankingDate = pickString(body.ranking_date) ?? null;
192
211
  const previousDate = pickString(body.previous_date) ?? null;
193
212
  const gapDays = (() => {
213
+ if (typeof body.gap_days === 'number')
214
+ return body.gap_days;
194
215
  if (!rankingDate || !previousDate)
195
216
  return null;
196
217
  const a = Date.parse(rankingDate);
@@ -205,6 +226,10 @@ Parallel-safe: yes. Upstream cost: 1.`,
205
226
  ? 'week-over-week'
206
227
  : `${Math.round(gapDays / 7)}-week gap`;
207
228
  const warnings = [];
229
+ if (droppedWithheld || newEntriesWithheld) {
230
+ warnings.push(pickString(body.coverage_note) ??
231
+ 'A compared list is held only in part, so drop-outs / new entries are withheld: absence from it does not mean unranked.');
232
+ }
208
233
  if (gapDays !== null && gapDays > 10) {
209
234
  warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
210
235
  `Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
@@ -239,6 +264,14 @@ Parallel-safe: yes. Upstream cost: 1.`,
239
264
  items,
240
265
  newEntries,
241
266
  dropped,
267
+ droppedWithheld,
268
+ newEntriesWithheld,
269
+ listCoverage: coverage
270
+ ? {
271
+ current: normalizeCoverage(coverage.current),
272
+ previous: normalizeCoverage(coverage.previous),
273
+ }
274
+ : null,
242
275
  total,
243
276
  },
244
277
  });
@@ -256,12 +289,13 @@ Prefer over: standings (latest snapshot only); rankings_movers (week-over-week d
256
289
  Do not use when: the current top-N table → standings with game tennis.
257
290
 
258
291
  Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
259
- wants; the newest entries are the tail. limit keeps the most RECENT N rows (the
260
- route itself ignores ?limit=, so the bound is applied here) — hasMore and
261
- oldestReturned/newestReturned say exactly which window came back, and
262
- omittedOlder counts the rows left behind at the old end.
292
+ wants; the newest entries are the tail. limit + page walk the series from the most
293
+ RECENT end (page 1 = the newest limit rows, page 2 the ones before them); the route
294
+ pages itself. hasMore and oldestReturned/newestReturned say exactly which window came
295
+ back, omittedOlder counts the rows left at the old end and omittedNewer the rows on
296
+ earlier (newer) pages.
263
297
 
264
- Parallel-safe: yes. Upstream cost: 1.`,
298
+ Parallel-safe: yes. API calls: 1.`,
265
299
  inputSchema: {
266
300
  type: 'object',
267
301
  additionalProperties: false,
@@ -271,6 +305,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
271
305
  playerId: stringSchema('Tennis player id, e.g. "atp_207989" or "wta_214544".', 'atp_207989'),
272
306
  since: stringSchema('Only rows on or after this date (YYYY-MM-DD).', '2026-01-01'),
273
307
  limit: limitSchema({ default: 20, max: 500, description: 'Max history rows (default 20, max 500).' }),
308
+ page: { type: 'integer', minimum: 1, default: 1, description: 'Page from the newest end (1 = the most recent limit rows).' },
274
309
  },
275
310
  },
276
311
  handler: async (args, ctx) => {
@@ -323,8 +358,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
323
358
  });
324
359
  }
325
360
  const limit = clampInt(args.limit, 20, 1, 500);
361
+ const page = clampInt(args.page, 1, 1, 100000);
326
362
  const res = await fetchJson(ctx, `/tennis/rankings/history/${encodeURIComponent(playerId)}`, {
327
- query: { limit },
363
+ query: { limit, page, ...(sinceRaw ? { from: sinceRaw } : {}) },
328
364
  });
329
365
  if (!res.ok) {
330
366
  return errorEnvelope({
@@ -354,8 +390,21 @@ Parallel-safe: yes. Upstream cost: 1.`,
354
390
  const inWindow = sinceRaw
355
391
  ? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
356
392
  : allHistory;
357
- const total = inWindow.length;
358
- const history = total > limit ? inWindow.slice(total - limit) : inWindow;
393
+ const pagedUpstream = typeof body.total === 'number' && typeof body.order === 'string';
394
+ let total;
395
+ let history;
396
+ if (pagedUpstream) {
397
+ total = body.total;
398
+ history = body.order === 'desc' ? [...inWindow].reverse() : inWindow;
399
+ }
400
+ else {
401
+ total = inWindow.length;
402
+ const end = Math.max(0, total - (page - 1) * limit);
403
+ history = inWindow.slice(Math.max(0, end - limit), end);
404
+ }
405
+ const omittedNewer = Math.min(total, (page - 1) * limit);
406
+ const omittedOlder = Math.max(0, total - omittedNewer - history.length);
407
+ const hasMore = pagedUpstream && typeof body.has_next === 'boolean' ? body.has_next : omittedOlder > 0;
359
408
  const best = history.reduce((acc, h) => {
360
409
  if (h.rank === null)
361
410
  return acc;
@@ -366,9 +415,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
366
415
  return successEnvelope({
367
416
  pagination: {
368
417
  limit,
369
- offset: total - history.length,
418
+ offset: omittedNewer,
370
419
  total,
371
- hasMore: total > history.length,
420
+ hasMore,
372
421
  nextCursor: null,
373
422
  prevCursor: null,
374
423
  },
@@ -391,7 +440,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
391
440
  history,
392
441
  totalInWindow: total,
393
442
  returned: history.length,
394
- omittedOlder: total - history.length,
443
+ omittedOlder,
444
+ omittedNewer,
445
+ page,
395
446
  oldestReturned: history.length ? history[0].date : null,
396
447
  newestReturned: history.length ? history[history.length - 1].date : null,
397
448
  },