cito-mcp 0.5.8 → 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
  });
@@ -262,7 +295,7 @@ pages itself. hasMore and oldestReturned/newestReturned say exactly which window
262
295
  back, omittedOlder counts the rows left at the old end and omittedNewer the rows on
263
296
  earlier (newer) pages.
264
297
 
265
- Parallel-safe: yes. Upstream cost: 1.`,
298
+ Parallel-safe: yes. API calls: 1.`,
266
299
  inputSchema: {
267
300
  type: 'object',
268
301
  additionalProperties: false,
@@ -31,7 +31,7 @@ function pushCandidates(out, game, type, rows, q, limit) {
31
31
  let matchedBy;
32
32
  if (q && score <= 0 && index === 0) {
33
33
  score = 95;
34
- matchedBy = 'upstream-first';
34
+ matchedBy = 'top-hit';
35
35
  }
36
36
  const secondary = {};
37
37
  for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
@@ -366,7 +366,7 @@ async function searchGame(ctx, game, q, type, limit) {
366
366
  candidates,
367
367
  calls,
368
368
  error: partialFromRejection(`game:${game}`, {
369
- code: 'UPSTREAM',
369
+ code: 'SERVICE_ERROR',
370
370
  message: e.message,
371
371
  }),
372
372
  };
@@ -389,7 +389,7 @@ Do not use when: you already have a stable id/slug from a prior tool.
389
389
 
390
390
  Empty/ambiguous results still return ok:true with best=null or needsDisambiguation=true — pick from candidates or refine q/game/type. Does not emit AMBIGUOUS_ENTITY as a hard error.
391
391
 
392
- Parallel-safe: yes. Upstream cost: 1–5.
392
+ Parallel-safe: yes. API calls: 1–5.
393
393
  Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
394
394
  inputSchema: {
395
395
  type: 'object',
@@ -507,7 +507,7 @@ Do not use when: fetching a known entity profile — use team_profile or player_
507
507
 
508
508
  UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
509
509
 
510
- Parallel-safe: yes. Upstream cost: 1–3.
510
+ Parallel-safe: yes. API calls: 1–3.
511
511
  Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
512
512
  inputSchema: {
513
513
  type: 'object',
@@ -792,11 +792,11 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
792
792
  meta: {
793
793
  ...(it.meta ?? {}),
794
794
  score: raw,
795
- ...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'upstream-first' } : {}),
795
+ ...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'top-hit' } : {}),
796
796
  },
797
797
  };
798
798
  });
799
- const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'upstream-first';
799
+ const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'top-hit';
800
800
  droppedBelowFloor = items
801
801
  .filter((it) => !keep(it))
802
802
  .map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
@@ -805,8 +805,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
805
805
  }
806
806
  if (q && !preserveUpstreamOrder) {
807
807
  items.sort((a, b) => {
808
- const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
809
- const bm = b.meta?.matchedBy === 'upstream-first' ? 1 : 0;
808
+ const am = a.meta?.matchedBy === 'top-hit' ? 1 : 0;
809
+ const bm = b.meta?.matchedBy === 'top-hit' ? 1 : 0;
810
810
  return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
811
811
  });
812
812
  }
@@ -820,8 +820,8 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
820
820
  .slice(0, 5)
821
821
  .map((d) => `${d.name} (${d.id})`)
822
822
  .join(', ');
823
- floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all — the upstream search is a ` +
824
- `substring match with no relevance floor, so it pads every page: ${sample}. ` +
823
+ floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all (the name search is a ` +
824
+ `substring match with no relevance floor, so it pads every page): ${sample}. ` +
825
825
  `If one of these is what you wanted, query it by name or id directly.`);
826
826
  }
827
827
  return successEnvelope({
@@ -849,7 +849,7 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
849
849
  matched: items.length,
850
850
  droppedBelowFloor: droppedBelowFloor.length,
851
851
  dropped: droppedBelowFloor.slice(0, 25),
852
- upstreamTotal: upstreamTotal ?? null,
852
+ unfilteredTotal: upstreamTotal ?? null,
853
853
  },
854
854
  }
855
855
  : {}),
@@ -54,7 +54,7 @@ export const tennisSchedule = {
54
54
  When to use:
55
55
  - "What tennis is on today?"; a specific day's fixtures; results completed on a given date; building a daily digest.
56
56
 
57
- Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (both require ?date=, which this tool supplies and validates); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
57
+ Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (this tool supplies and validates the date, and bounds the rows); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
58
58
 
59
59
  Do not use when: matches currently in play → live_matches; a named player's history → player_matches.
60
60
 
@@ -62,13 +62,14 @@ Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
62
62
  'include': both (default), scheduled, or completed. Both halves are fetched in
63
63
  parallel, so either can fail on its own without losing the other.
64
64
 
65
- 'limit' bounds EACH half (the completed route ignores ?limit= upstream, so the
66
- bound is applied here); scheduledCount/completedCount report how many rows the
65
+ 'limit' bounds EACH half (the bound is applied by this tool); scheduledCount/completedCount report how many rows the
67
66
  day held and *Returned how many came back. The two halves overlap: the day's
68
67
  list includes matches already played, so the same matchId can appear in both —
69
68
  see the overlap block before de-duplicating or counting.
70
69
 
71
- Parallel-safe: yes. Upstream cost: 1 or 2.`,
70
+ Rows: start_time_known false means no start time is held (scheduled_at is then midnight). start_time_kind says whether scheduled_at is when the match actually started (actual) or the published order-of-play time (scheduled). Read status for where a match is now and outcome for how it ended.
71
+
72
+ Parallel-safe: yes. API calls: 1 or 2.`,
72
73
  inputSchema: {
73
74
  type: 'object',
74
75
  additionalProperties: false,
@@ -172,7 +173,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
172
173
  else if (schedRes.status === 'rejected') {
173
174
  rejected.push({
174
175
  section: 'schedule',
175
- code: 'UPSTREAM',
176
+ code: 'SERVICE_ERROR',
176
177
  message: String(schedRes.reason).slice(0, 200),
177
178
  });
178
179
  }
@@ -194,7 +195,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
194
195
  else if (compRes.status === 'rejected') {
195
196
  rejected.push({
196
197
  section: 'completed',
197
- code: 'UPSTREAM',
198
+ code: 'SERVICE_ERROR',
198
199
  message: String(compRes.reason).slice(0, 200),
199
200
  });
200
201
  }