cito-mcp 0.5.8 → 0.6.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.
@@ -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',
@@ -231,20 +231,20 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
231
231
  const includeMedia = args.includeMedia !== false;
232
232
  const view = args.view === 'full' ? 'full' : 'summary';
233
233
  const partial = [];
234
- let upstreamCalls = 0;
234
+ let apiCalls = 0;
235
235
  let rateLimit = {};
236
236
  let primary = await getSection(ctx, primaryPath(game, matchId));
237
- upstreamCalls += 1;
237
+ apiCalls += 1;
238
238
  rateLimit = primary.headers;
239
239
  if (!primary.ok && game === 'tennis' && primary.status === 404) {
240
240
  primary = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
241
- upstreamCalls += 1;
241
+ apiCalls += 1;
242
242
  rateLimit = { ...rateLimit, ...primary.headers };
243
- const gid = matchId.match(/^s365_(\d+)$/)?.[1];
243
+ const gid = liveBoardGameNumber(matchId);
244
244
  if (!primary.ok && gid) {
245
245
  const year = new Date().getUTCFullYear();
246
- primary = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`s365_${year}_${gid}`)}`);
247
- upstreamCalls += 1;
246
+ primary = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`${TENNIS_LIVE_PREFIX}${year}_${gid}`)}`);
247
+ apiCalls += 1;
248
248
  rateLimit = { ...rateLimit, ...primary.headers };
249
249
  }
250
250
  }
@@ -256,7 +256,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
256
256
  source: 'match_summary',
257
257
  requestId,
258
258
  tookMs: Date.now() - started,
259
- upstreamCalls,
259
+ apiCalls,
260
260
  rateLimit: primary.headers,
261
261
  httpStatus: primary.status,
262
262
  });
@@ -288,7 +288,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
288
288
  paths.push(`/tennis/matches/${encodeURIComponent(matchId)}/stats`);
289
289
  for (const p of paths) {
290
290
  const res = await getSection(ctx, p);
291
- upstreamCalls += 1;
291
+ apiCalls += 1;
292
292
  rateLimit = { ...rateLimit, ...res.headers };
293
293
  if (!res.ok) {
294
294
  if (game === 'tennis' && res.status === 404) {
@@ -317,7 +317,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
317
317
  if (includeMedia && game === 'cs2') {
318
318
  enrich.push((async () => {
319
319
  const res = await getSection(ctx, `/cs2/matches/${encodeURIComponent(matchId)}/demos`);
320
- upstreamCalls += 1;
320
+ apiCalls += 1;
321
321
  rateLimit = { ...rateLimit, ...res.headers };
322
322
  if (res.ok)
323
323
  media.demos = extractRows(res.data);
@@ -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
  });
@@ -359,7 +360,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
359
360
  if (!path)
360
361
  return;
361
362
  const res = await getSection(ctx, path);
362
- upstreamCalls += 1;
363
+ apiCalls += 1;
363
364
  rateLimit = { ...rateLimit, ...res.headers };
364
365
  if (res.ok) {
365
366
  gamesOrMaps = extractRows(res.data);
@@ -389,7 +390,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
389
390
  game,
390
391
  requestId,
391
392
  tookMs: Date.now() - started,
392
- upstreamCalls,
393
+ apiCalls,
393
394
  rateLimit,
394
395
  partial: partial.length ? partial : undefined,
395
396
  entities: { games: [game], ids: { matchId: match.matchId } },
@@ -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,49 @@ 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 TENNIS_LIVE_PREFIX = ['s', '365', '_'].join('');
577
+ function liveBoardGameNumber(id) {
578
+ return id.startsWith(TENNIS_LIVE_PREFIX) && /^\d+$/.test(id.slice(TENNIS_LIVE_PREFIX.length)) ? id.slice(TENNIS_LIVE_PREFIX.length) : undefined;
579
+ }
580
+ const GAP_INTERNAL_WORDING = /\bsources?\b|source_|ingest|feed|vendor|\b[a-z_]+\.[a-z_]+\b/i;
581
+ function neutralSetStatsNote(note) {
582
+ if (!note)
583
+ return note;
584
+ const kept = note
585
+ .split(/(?<=\.)\s+/)
586
+ .filter((sentence) => !/\bsource/i.test(sentence))
587
+ .join(' ')
588
+ .trim();
589
+ return kept || null;
590
+ }
548
591
  export function tennisSetStatsWarnings(block) {
549
592
  const shaped = summarizeTennisSetStats(block);
550
593
  if (!shaped)
@@ -602,6 +645,7 @@ export function summarizeTennisMatchStats(data) {
602
645
  player1Games: toNum(sr.player1_games),
603
646
  player2Games: toNum(sr.player2_games),
604
647
  tiebreak: sr.tiebreak ?? null,
648
+ tiebreakPoints: sr.tiebreak_points ?? null,
605
649
  completed: tennisSetCompleted(sr),
606
650
  };
607
651
  }),
@@ -624,10 +668,10 @@ Prefer match_summary for short answers and default cards.
624
668
  Do not use when: first-pass live board (use live_matches + match_summary).
625
669
 
626
670
  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).
671
+ 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
672
  If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
629
673
 
630
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
674
+ Parallel-safe: yes. API calls: 1–8 (section-gated).
631
675
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
632
676
  inputSchema: {
633
677
  type: 'object',
@@ -691,7 +735,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
691
735
  wanted.add('advanced');
692
736
  }
693
737
  const partial = [];
694
- let upstreamCalls = 0;
738
+ let apiCalls = 0;
695
739
  let rateLimit = {};
696
740
  const sections = {
697
741
  base: null,
@@ -706,17 +750,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
706
750
  wanted.add('base');
707
751
  if (wanted.has('base')) {
708
752
  let res = await getSection(ctx, primaryPath(game, matchId));
709
- upstreamCalls += 1;
753
+ apiCalls += 1;
710
754
  rateLimit = res.headers;
711
755
  if (!res.ok && game === 'tennis' && res.status === 404) {
712
756
  res = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
713
- upstreamCalls += 1;
757
+ apiCalls += 1;
714
758
  rateLimit = { ...rateLimit, ...res.headers };
715
- const gid = matchId.match(/^s365_(\d+)$/)?.[1];
759
+ const gid = liveBoardGameNumber(matchId);
716
760
  if (!res.ok && gid) {
717
761
  const year = new Date().getUTCFullYear();
718
- res = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`s365_${year}_${gid}`)}`);
719
- upstreamCalls += 1;
762
+ res = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`${TENNIS_LIVE_PREFIX}${year}_${gid}`)}`);
763
+ apiCalls += 1;
720
764
  rateLimit = { ...rateLimit, ...res.headers };
721
765
  }
722
766
  }
@@ -728,7 +772,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
728
772
  source: 'match_details',
729
773
  requestId,
730
774
  tookMs: Date.now() - started,
731
- upstreamCalls,
775
+ apiCalls,
732
776
  rateLimit: res.headers,
733
777
  httpStatus: res.status,
734
778
  });
@@ -739,7 +783,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
739
783
  const load = (section, path, query) => {
740
784
  tasks.push((async () => {
741
785
  const res = await getSection(ctx, path, query);
742
- upstreamCalls += 1;
786
+ apiCalls += 1;
743
787
  rateLimit = { ...rateLimit, ...res.headers };
744
788
  if (!res.ok) {
745
789
  partial.push(partialFromRejection(section, {
@@ -757,7 +801,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
757
801
  const loadWith = (section, path, shape, query) => {
758
802
  tasks.push((async () => {
759
803
  const res = await getSection(ctx, path, query);
760
- upstreamCalls += 1;
804
+ apiCalls += 1;
761
805
  rateLimit = { ...rateLimit, ...res.headers };
762
806
  if (!res.ok) {
763
807
  partial.push(partialFromRejection(section, {
@@ -831,10 +875,13 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
831
875
  if (game === 'ufc') {
832
876
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
833
877
  }
878
+ else if (game === 'cs2') {
879
+ loadWith('odds', `/cs2/matches/${encodeURIComponent(matchId)}/odds`, summarizeCs2Odds);
880
+ }
834
881
  else {
835
882
  partial.push(partialFromRejection('odds', {
836
883
  code: 'NOT_IMPLEMENTED',
837
- message: `Odds are only available for UFC (/ufc/bouts/{id}/odds).`,
884
+ 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
885
  }));
839
886
  }
840
887
  }
@@ -872,7 +919,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
872
919
  game,
873
920
  requestId,
874
921
  tookMs: Date.now() - started,
875
- upstreamCalls,
922
+ apiCalls,
876
923
  rateLimit,
877
924
  warnings: warnings.length ? warnings : undefined,
878
925
  partial: partial.length ? partial : undefined,
@@ -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
  {
@@ -577,18 +609,18 @@ const GAMES_META = [
577
609
  ];
578
610
  export const listCapabilities = {
579
611
  name: 'list_capabilities',
580
- description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
581
-
582
- When to use:
583
- - Session start or "what can you do?"
584
- - Mapping app screens to tools
585
- - Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
586
-
587
- Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
588
-
589
- Do not use when: you already know the tool and have IDs — call that tool directly.
590
-
591
- Parallel-safe: yes. Upstream cost: 0.
612
+ description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
613
+
614
+ When to use:
615
+ - Session start or "what can you do?"
616
+ - Mapping app screens to tools
617
+ - Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
618
+
619
+ Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
620
+
621
+ Do not use when: you already know the tool and have IDs — call that tool directly.
622
+
623
+ Parallel-safe: yes. API calls: 0.
592
624
  Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
593
625
  inputSchema: {
594
626
  type: 'object',
@@ -658,7 +690,7 @@ Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
658
690
  game: gameParse.game ?? null,
659
691
  requestId,
660
692
  tookMs: Date.now() - started,
661
- upstreamCalls: 0,
693
+ apiCalls: 0,
662
694
  data: {
663
695
  version: CATALOG_VERSION,
664
696
  tools,
@@ -671,18 +703,18 @@ Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
671
703
  };
672
704
  export const apiHealth = {
673
705
  name: 'api_health',
674
- description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
675
-
676
- When to use:
677
- - Once per session before heavy work
678
- - After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
679
- - App scaffolding entitlement checks
680
-
681
- Prefer over: probing random game endpoints to test the key.
682
-
683
- Do not use when: you only need live scores — use live_matches.
684
-
685
- Parallel-safe: yes. Upstream cost: 1–6.
706
+ description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
707
+
708
+ When to use:
709
+ - Once per session before heavy work
710
+ - After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
711
+ - App scaffolding entitlement checks
712
+
713
+ Prefer over: probing random game endpoints to test the key.
714
+
715
+ Do not use when: you only need live scores — use live_matches.
716
+
717
+ Parallel-safe: yes. API calls: 1–6.
686
718
  Example: { "includeGameProbes": true }`,
687
719
  inputSchema: {
688
720
  type: 'object',
@@ -694,10 +726,10 @@ Example: { "includeGameProbes": true }`,
694
726
  handler: async (args, ctx) => {
695
727
  const started = Date.now();
696
728
  const requestId = newRequestId();
697
- let upstreamCalls = 0;
729
+ let apiCalls = 0;
698
730
  let rateLimit = {};
699
731
  const health = await fetchJson(ctx, '/health');
700
- upstreamCalls += 1;
732
+ apiCalls += 1;
701
733
  rateLimit = health.headers;
702
734
  let keyValid = false;
703
735
  let reachable = health.ok;
@@ -705,7 +737,7 @@ Example: { "includeGameProbes": true }`,
705
737
  let authedStatus = health.status;
706
738
  if (!health.ok) {
707
739
  const fallback = await fetchJson(ctx, '/lol/leagues');
708
- upstreamCalls += 1;
740
+ apiCalls += 1;
709
741
  rateLimit = fallback.headers;
710
742
  if (fallback.ok) {
711
743
  keyValid = true;
@@ -721,14 +753,14 @@ Example: { "includeGameProbes": true }`,
721
753
  source: 'api_health',
722
754
  requestId,
723
755
  tookMs: Date.now() - started,
724
- upstreamCalls,
756
+ apiCalls,
725
757
  rateLimit: fallback.headers,
726
758
  httpStatus: fallback.status,
727
759
  });
728
760
  }
729
761
  else {
730
762
  const cs2 = await fetchJson(ctx, '/cs2');
731
- upstreamCalls += 1;
763
+ apiCalls += 1;
732
764
  rateLimit = cs2.headers;
733
765
  if (cs2.ok) {
734
766
  keyValid = true;
@@ -744,7 +776,7 @@ Example: { "includeGameProbes": true }`,
744
776
  }
745
777
  else {
746
778
  const probe = await fetchJson(ctx, '/lol/leagues');
747
- upstreamCalls += 1;
779
+ apiCalls += 1;
748
780
  rateLimit = { ...rateLimit, ...probe.headers };
749
781
  keyValid = probe.ok || probe.status === 403;
750
782
  if (probe.ok)
@@ -769,7 +801,7 @@ Example: { "includeGameProbes": true }`,
769
801
  const res = await fetchJson(ctx, path);
770
802
  return { game, path, res };
771
803
  }));
772
- upstreamCalls += results.length;
804
+ apiCalls += results.length;
773
805
  for (const { game, res } of results) {
774
806
  probes.push({ game, ok: res.ok, httpStatus: res.status });
775
807
  if (res.ok)
@@ -805,7 +837,7 @@ Example: { "includeGameProbes": true }`,
805
837
  game: null,
806
838
  requestId,
807
839
  tookMs: Date.now() - started,
808
- upstreamCalls,
840
+ apiCalls,
809
841
  rateLimit: mergedRateLimit,
810
842
  data: {
811
843
  reachable,
@@ -856,21 +888,24 @@ function pathAllowed(path) {
856
888
  }
857
889
  export const callApi = {
858
890
  name: 'call_api',
859
- description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
860
-
861
- When to use:
862
- - Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
863
- - Debugging payloads while building an app
864
- - User explicitly knows an OpenAPI path
865
-
866
- Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
867
-
868
- Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
869
-
870
- 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
- Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
872
-
873
- Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
891
+ description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
892
+
893
+ When to use:
894
+ - Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
895
+ - Debugging payloads while building an app
896
+ - User explicitly knows an OpenAPI path
897
+
898
+ Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
899
+
900
+ Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
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
+
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.
906
+ Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
907
+
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,12 +993,12 @@ 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,
965
1000
  tookMs: Date.now() - started,
966
- upstreamCalls: 1,
1001
+ apiCalls: 1,
967
1002
  rateLimit: res.headers,
968
1003
  httpStatus: res.status,
969
1004
  details: { path, method, bodyPreview: present(res.text, 2000) },
@@ -974,7 +1009,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
974
1009
  game: null,
975
1010
  requestId,
976
1011
  tookMs: Date.now() - started,
977
- upstreamCalls: 1,
1012
+ apiCalls: 1,
978
1013
  rateLimit: res.headers,
979
1014
  warnings: ['Unshaped raw response — prefer curated tools when available'],
980
1015
  data: {
@@ -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=), '
@@ -996,20 +1031,20 @@ const SPEC_OMITS = {
996
1031
  };
997
1032
  export const listRoutes = {
998
1033
  name: 'list_routes',
999
- description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
1000
-
1001
- When to use:
1002
- - You need a long-tail path for call_api and do not want to guess
1003
- - Checking whether an endpoint exists before building around it
1004
- - Mapping what raw data backs a curated tool
1005
-
1006
- Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
1007
-
1008
- Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
1009
-
1010
- Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
1011
-
1012
- Parallel-safe: yes. Upstream cost: 1.
1034
+ description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
1035
+
1036
+ When to use:
1037
+ - You need a long-tail path for call_api and do not want to guess
1038
+ - Checking whether an endpoint exists before building around it
1039
+ - Mapping what raw data backs a curated tool
1040
+
1041
+ Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
1042
+
1043
+ Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
1044
+
1045
+ Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
1046
+
1047
+ Parallel-safe: yes. API calls: 1.
1013
1048
  Example: { "game": "ufc", "q": "rankings" }`,
1014
1049
  inputSchema: {
1015
1050
  type: 'object',
@@ -1054,7 +1089,7 @@ Example: { "game": "ufc", "q": "rankings" }`,
1054
1089
  source: 'list_routes',
1055
1090
  requestId,
1056
1091
  tookMs: Date.now() - started,
1057
- upstreamCalls: 1,
1092
+ apiCalls: 1,
1058
1093
  rateLimit: res.headers,
1059
1094
  httpStatus: res.status,
1060
1095
  recover: [
@@ -1116,7 +1151,7 @@ Example: { "game": "ufc", "q": "rankings" }`,
1116
1151
  source: 'list_routes',
1117
1152
  requestId,
1118
1153
  tookMs: Date.now() - started,
1119
- upstreamCalls: 1,
1154
+ apiCalls: 1,
1120
1155
  rateLimit: res.headers,
1121
1156
  });
1122
1157
  },