cito-mcp 0.4.2 → 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` |
@@ -213,9 +212,27 @@ When a key is `null` the image does not exist for that entity; when the whole
213
212
  never partially shaped — if any image exists, all four keys are present.
214
213
 
215
214
  Tennis is the exception on purpose: the feed supplies a single `portrait_url`
216
- (Wikimedia) and no square crop, so that one URL fills both `headshotUrl` and
217
- `imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
218
- a proxy. A card with the real photo slightly cropped beats a faceless card.
215
+ (Wikimedia Commons) and no square crop, so that one URL fills both `headshotUrl`
216
+ and `imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
217
+ a proxy.
218
+
219
+ **Tennis images carry their licence and credit** — and a UI is expected to render
220
+ them:
221
+
222
+ ```jsonc
223
+ "images": {
224
+ "headshotUrl": "https://commons.wikimedia.org/wiki/Special:FilePath/Hoang_WMQ18_%2833%29_%2842647295495%29.jpg?width=400",
225
+ "license": "CC BY-SA 2.0",
226
+ "attribution": "si.robi",
227
+ "sourceUrl": "https://commons.wikimedia.org/wiki/File:Hoang_WMQ18_(33)_(42647295495).jpg"
228
+ }
229
+ ```
230
+
231
+ Commons portraits are mostly CC BY / CC BY-SA, which for commercial display
232
+ generally requires naming the author and stating the licence. `attribution` is
233
+ `null` for public-domain and CC0 images, which need no credit — that is a fact,
234
+ not missing data. Anything without a defensible licence has its URL removed
235
+ upstream, so a non-null `headshotUrl` always has a `license` beside it.
219
236
 
220
237
  ### Games
221
238
 
@@ -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',
@@ -54,16 +54,29 @@ function identityFrom(game, raw, idHint, slugHint) {
54
54
  */
55
55
  images: {
56
56
  // Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
57
- // portrait), and no separate square headshot crop. Before this, the shaper
58
- // read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of which the
59
- // tennis route emits — so every tennis profile shipped four explicit nulls
60
- // while a perfectly good photo sat one key away, and builders concluded
61
- // tennis had no images at all. The portrait fills the headshot slot too:
62
- // a card with the real photo slightly cropped beats a faceless card.
57
+ // Commons portrait), and no separate square headshot crop. Before this,
58
+ // the shaper read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of
59
+ // which the tennis route emits — so every tennis profile shipped four
60
+ // explicit nulls while a perfectly good photo sat one key away.
63
61
  headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
64
62
  bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
65
63
  imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
66
64
  proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
65
+ /**
66
+ * The licence and the credit, carried with the URL.
67
+ *
68
+ * Commons portraits are mostly CC BY / CC BY-SA, which for commercial
69
+ * display generally obliges you to name the author and state the licence.
70
+ * Handing back a bare URL and letting a builder render it silently is what
71
+ * gets a product a takedown letter, so the fields a UI must show travel in
72
+ * the same object as the image. `attribution` is null for public-domain
73
+ * and CC0 images, which need no credit — that is a fact, not missing data.
74
+ * Anything without a defensible licence has its URL removed upstream, so a
75
+ * non-null headshotUrl here always has a licence beside it.
76
+ */
77
+ license: pickString(r.portrait_license) ?? null,
78
+ attribution: pickString(r.portrait_attribution) ?? null,
79
+ sourceUrl: pickString(r.portrait_source_url) ?? null,
67
80
  },
68
81
  };
69
82
  }
@@ -892,15 +892,25 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
892
892
  const upstreamTotal = total;
893
893
  let droppedBelowFloor = [];
894
894
  /**
895
- * The first row the upstream returned is exempt from the floor.
895
+ * The upstream's own top ENTITY row is exempt from the floor.
896
896
  *
897
897
  * The upstream's dedicated search knows aliases the payload does not carry:
898
898
  * `q=Nole` returns Novak Djokovic first, and rankScore cannot see why, so a
899
899
  * pure floor would delete the correct answer and keep substring collisions
900
- * instead. Exactly one row is protected, and only when a query was given, so
901
- * the floor still removes all the padding it was added for.
900
+ * instead.
901
+ *
902
+ * This used to be the global items[0], which was wrong: search_entities
903
+ * merges several sources CONCURRENTLY (for tennis, /players/search and
904
+ * /competitions), so whichever promise settled first decided which row got
905
+ * protected. The live gate caught it as a flaky "first result is Jannik
906
+ * Sinner" — a competition could land at index 0 and the alias protection
907
+ * would guard the wrong thing. Only entity rows (player/team/fighter) are
908
+ * alias-matched at all; tournaments are matched by name and have no
909
+ * nicknames, so taking the first entity row is both deterministic and the
910
+ * row the rule was written for.
902
911
  */
903
- const protectedRow = q && items.length > 0 ? items[0] : null;
912
+ const ENTITY_TYPES = new Set(['player', 'team', 'fighter']);
913
+ const protectedRow = q ? items.find((it) => ENTITY_TYPES.has(it.type)) ?? null : null;
904
914
  const protectedKey = protectedRow ? `${protectedRow.game}:${protectedRow.type}:${protectedRow.id}` : null;
905
915
  if (q) {
906
916
  // Publish the score on every row, not just the ones the scored branch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.2",
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": {