cito-mcp 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,6 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
28
28
  | Pre-match briefing (with surface) | `match_preview` |
29
29
  | Event / fight-night card | `event_card` |
30
30
  | Rivalry record | `head_to_head` |
31
- | Tennis betting odds | `tennis_odds` |
32
31
  | Tennis tournament catalog | `tournaments` |
33
32
  | One day of tennis | `tennis_schedule` |
34
33
  | Tennis player match log | `player_matches` |
@@ -9,7 +9,6 @@ import { rankingsTools } from './rankings.js';
9
9
  import { leaderboardTools } from './leaderboard.js';
10
10
  import { insightTools } from './insight.js';
11
11
  import { cs2Tools } from './cs2.js';
12
- import { oddsTools } from './odds.js';
13
12
  import { tournamentTools } from './tournaments.js';
14
13
  import { scheduleTools } from './schedule.js';
15
14
  /** Curated outcome-tool catalog. Order matches preferred cold-start ladder. */
@@ -25,7 +24,13 @@ export const allTools = [
25
24
  ...leaderboardTools,
26
25
  // Tennis depth added after the coverage audit: odds, tournament discovery and
27
26
  // the daily schedule were reachable only through call_api.
28
- ...oddsTools,
27
+ //
28
+ // oddsTools REMOVED 2026-09-13. Tennis odds were withdrawn from the API
29
+ // (/tennis/odds/* is unmounted), so the tool would only ever call a 404.
30
+ // Betting data concentrates the legal risk and is the subject of every
31
+ // significant dispute researched — Swish Analytics v OddsJam is a data vendor
32
+ // suing rivals over scraped odds. See docs/tennis-risk-register.md.
33
+ // To restore: re-add `...oddsTools,` here and remount odds_router in the API.
29
34
  ...tournamentTools,
30
35
  ...scheduleTools,
31
36
  ...insightTools,
@@ -4,7 +4,10 @@
4
4
  import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
6
  import { normalizeMatch, normalizeUfcMethod, presentSides, tennisSetCompleted } from './normalize.js';
7
- import { summarizeTennisOdds } from './odds.js';
7
+ // summarizeTennisOdds was used here for the tennis odds section. Tennis odds were
8
+ // withdrawn 2026-09-13 (see docs/tennis-risk-register.md), so this module no
9
+ // longer imports it. The shaper still exists and is still exported from
10
+ // ./odds.js for the day the surface is restored.
8
11
  import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
9
12
  async function getSection(ctx, path, query) {
10
13
  return fetchJson(ctx, path, { query });
@@ -552,15 +555,127 @@ export function summarizeUfcOdds(data) {
552
555
  fullBookPath: '/ufc/bouts/{boutId}/odds',
553
556
  };
554
557
  }
558
+ /**
559
+ * Per-period ("ALL / 1ST / 2ND / 3RD") statistics.
560
+ *
561
+ * The REST route gained `set_stats`: the same serving/returning figures split by
562
+ * set. Every field is nullable and a null is a STATEMENT, not a zero -- 0 aces is
563
+ * a claim, "we do not know" is not. `missing` names the nulls per side and `gaps`
564
+ * says why each one is absent, so a caller can tell these three apart:
565
+ * PERIOD_NO_STATS no stats exist for that period at all
566
+ * INCOMPLETE_PERIOD_STATS the period exists but a field is unknown
567
+ * SOURCE_UNAVAILABLE / SOURCE_NOT_MAPPED
568
+ * a source was consulted and did not deliver
569
+ * Collapsing any of that into zero, or dropping `gaps`, would delete the very
570
+ * information that makes the block trustworthy.
571
+ */
572
+ export function summarizeTennisSetStats(block) {
573
+ const b = asRecord(block);
574
+ if (!b)
575
+ return null;
576
+ const arr = (v) => (Array.isArray(v) ? v : []);
577
+ const strings = (v) => arr(v).map(String);
578
+ /**
579
+ * An object carrying nothing at all is not a block: the API sends
580
+ * `set_stats: null` when it has no rows, so `{}` can only be a caller's stub,
581
+ * and `setStats: null` says "no information" more honestly than a block of
582
+ * empty arrays. A block with ONLY gaps is kept, because the gaps are the
583
+ * information.
584
+ */
585
+ if (!arr(b.periods).length && !arr(b.gaps).length && !arr(b.sources).length
586
+ && typeof b.note !== 'string') {
587
+ return null;
588
+ }
589
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
590
+ const str = (v) => (typeof v === 'string' ? v : null);
591
+ const line = (side) => {
592
+ const s = asRecord(side) ?? {};
593
+ return {
594
+ aces: num(s.aces),
595
+ doubleFaults: num(s.double_faults),
596
+ firstServePct: num(s.first_serve_pct),
597
+ firstServeWonPct: num(s.first_serve_win_pct),
598
+ secondServeWonPct: num(s.second_serve_win_pct),
599
+ breakPointsSaved: num(s.break_points_saved),
600
+ breakPointsFaced: num(s.break_points_faced),
601
+ breakPointsConverted: num(s.break_points_converted),
602
+ breakPointOpportunities: num(s.break_point_opportunities),
603
+ totalPointsWon: num(s.total_points_won),
604
+ servicePointsPlayed: num(s.serve_points),
605
+ servicePointsWon: num(s.service_points_won),
606
+ gamesWon: num(s.games_won),
607
+ };
608
+ };
609
+ return {
610
+ periods: arr(b.periods).map((p) => {
611
+ const pr = asRecord(p) ?? {};
612
+ const missing = asRecord(pr.missing) ?? {};
613
+ return {
614
+ period: str(pr.period) ?? 'ALL',
615
+ setNumber: num(pr.set_number) ?? 0,
616
+ status: str(pr.status) ?? 'partial',
617
+ sources: strings(pr.sources),
618
+ winner: line(pr.winner),
619
+ loser: line(pr.loser),
620
+ missing: { winner: strings(missing.winner), loser: strings(missing.loser) },
621
+ };
622
+ }),
623
+ sources: arr(b.sources).map((s) => {
624
+ const sr = asRecord(s) ?? {};
625
+ return {
626
+ source: str(sr.source) ?? 'unknown',
627
+ status: str(sr.status) ?? 'unknown',
628
+ fields: strings(sr.fields),
629
+ detail: str(sr.detail),
630
+ };
631
+ }),
632
+ gaps: arr(b.gaps).map((g) => {
633
+ const gr = asRecord(g) ?? {};
634
+ return {
635
+ code: str(gr.code) ?? 'UNKNOWN',
636
+ period: str(gr.period),
637
+ setNumber: num(gr.set_number),
638
+ side: str(gr.side),
639
+ fields: strings(gr.fields),
640
+ source: str(gr.source),
641
+ status: str(gr.status),
642
+ message: str(gr.message) ?? '',
643
+ };
644
+ }),
645
+ note: str(b.note),
646
+ };
647
+ }
648
+ /**
649
+ * One line per stated absence, for the envelope's meta.warnings. The gaps also
650
+ * ride inside the section; this is what makes them visible to a client that only
651
+ * reads the envelope.
652
+ */
653
+ export function tennisSetStatsWarnings(block) {
654
+ const shaped = summarizeTennisSetStats(block);
655
+ if (!shaped)
656
+ return [];
657
+ const gaps = shaped.gaps ?? [];
658
+ const out = [];
659
+ for (const gap of gaps) {
660
+ const where = [gap.period, gap.side].filter(Boolean).join(' ');
661
+ out.push(`set_stats ${gap.code}${where ? ` (${where})` : ''}: ${gap.message}`);
662
+ }
663
+ // One warning per distinct code is enough for a client that only needs to know
664
+ // the block is not fully populated; the section carries the detail.
665
+ const note = shaped.note;
666
+ if (gaps.length && typeof note === 'string')
667
+ out.unshift(`set_stats: ${note}`);
668
+ return out.slice(0, 6);
669
+ }
555
670
  /**
556
671
  * Tennis per-match stats projection.
557
672
  *
558
673
  * `/tennis/matches/{id}/stats` does NOT return a row per player the way every
559
674
  * other game's player-stats route does. It returns
560
- * `{ stats: { winner: {...}, loser: {...} }, sets: [...], score }` — two objects
561
- * keyed by OUTCOME. `match_details sections:["playerStats"]` had no tennis arm
562
- * at all, so it answered `playerStats: null` while `match_summary` was filling
563
- * `playerPerformances` from the same route.
675
+ * `{ stats: { winner: {...}, loser: {...} }, sets: [...], score, set_stats }` —
676
+ * two objects keyed by OUTCOME. `match_details sections:["playerStats"]` had no
677
+ * tennis arm at all, so it answered `playerStats: null` while `match_summary`
678
+ * was filling `playerPerformances` from the same route.
564
679
  *
565
680
  * The sides stay keyed by outcome rather than being mislabelled as player1 /
566
681
  * player2, because the payload carries no names and the ordering is not
@@ -595,6 +710,9 @@ export function summarizeTennisMatchStats(data) {
595
710
  completed: tennisSetCompleted(sr),
596
711
  };
597
712
  }),
713
+ // ALL / 1ST / 2ND / 3RD ... Null when the API has no per-period rows for
714
+ // this match, which is a different statement from an ingested empty period.
715
+ setStats: summarizeTennisSetStats(payload.set_stats),
598
716
  rawPath: '/tennis/matches/{matchId}/stats',
599
717
  };
600
718
  }
@@ -632,7 +750,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
632
750
  type: 'string',
633
751
  enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
634
752
  },
635
- description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted); tennis returns the same bookmaker/market/outcome projection that tennis_odds serves, including oddsAvailable=false when no book is quoting. Odds exist for UFC and tennis only.',
753
+ description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted). Tennis odds were withdrawn alongside the /tennis/odds/* routes, so for any other game the section reports NOT_IMPLEMENTED. Odds exist for UFC only.',
636
754
  },
637
755
  includeTimeline: boolSchema('Include timeline section (heavy).', false),
638
756
  includeLiveState: boolSchema('Include live state/snapshots.', false),
@@ -835,17 +953,15 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
835
953
  if (game === 'ufc') {
836
954
  loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
837
955
  }
838
- else if (game === 'tennis') {
839
- // The gate that produced "Odds not curated for tennis; UFC only today"
840
- // was written before /tennis/odds/{id} existed. It does: the same match
841
- // returned FanDuel 1.105 / Matchbook 1.13 from tennis_odds while this
842
- // section denied odds existed. Both surfaces now share one projection.
843
- loadWith('odds', `/tennis/odds/${encodeURIComponent(matchId)}`, summarizeTennisOdds);
844
- }
845
956
  else {
957
+ // Tennis odds were REMOVED 2026-09-13 alongside the /tennis/odds/* routes.
958
+ // Betting data concentrates the legal risk and is the subject of every
959
+ // significant dispute researched (see docs/tennis-risk-register.md), so
960
+ // it is no longer offered. Requesting the section is now an explicit
961
+ // NOT_IMPLEMENTED rather than a call to a route that returns 404.
846
962
  partial.push(partialFromRejection('odds', {
847
963
  code: 'NOT_IMPLEMENTED',
848
- message: `Odds are not curated for ${game}. Available: UFC (/ufc/bouts/{id}/odds) and tennis (/tennis/odds/{id}).`,
964
+ message: `Odds are not curated for ${game}. Currently available: UFC (/ufc/bouts/{id}/odds). Tennis odds were withdrawn.`,
849
965
  }));
850
966
  }
851
967
  }
@@ -872,6 +988,15 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
872
988
  }
873
989
  }
874
990
  await Promise.all(tasks);
991
+ // Per-period coverage gaps ride in meta.warnings as well as inside the
992
+ // playerStats section, so a client that only reads the envelope still learns
993
+ // that the ALL / 1ST / 2ND / 3RD block is not fully populated and why.
994
+ const warnings = [];
995
+ if (game === 'tennis') {
996
+ const ps = asRecord(sections.playerStats);
997
+ if (ps)
998
+ warnings.push(...tennisSetStatsWarnings(ps.setStats));
999
+ }
875
1000
  return successEnvelope({
876
1001
  source: 'match_details',
877
1002
  game,
@@ -879,6 +1004,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
879
1004
  tookMs: Date.now() - started,
880
1005
  upstreamCalls,
881
1006
  rateLimit,
1007
+ warnings: warnings.length ? warnings : undefined,
882
1008
  partial: partial.length ? partial : undefined,
883
1009
  entities: { games: [game], ids: { matchId, ...(gameId ? { gameId } : {}) } },
884
1010
  data: {
@@ -186,16 +186,6 @@ const TOOL_CATALOG = [
186
186
  preferOver: ['raw rankings history via call_api'],
187
187
  doNotUse: 'Latest snapshot → standings; week-over-week delta → rankings_movers',
188
188
  },
189
- {
190
- name: 'tennis_odds',
191
- outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match; upcoming rows carry a joinKey and playerIds because the feed leaves match_id null',
192
- parallelSafe: true,
193
- games: ['tennis'],
194
- jobs: ['preview', 'match_page', 'odds'],
195
- exampleArgs: { game: 'tennis', scope: 'upcoming', limit: 20 },
196
- preferOver: ['raw odds via call_api', 'match_preview (no prices)'],
197
- doNotUse: 'The result → match_details; rankings → standings',
198
- },
199
189
  {
200
190
  name: 'tournaments',
201
191
  outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country; a level name that names a tour also fixes it',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Standalone MCP server for the Cito esports and sports API — 42 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
6
  "bin": {