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 +0 -1
- package/dist/tools/index.js +7 -2
- package/dist/tools/match.js +140 -14
- package/dist/tools/meta.js +0 -10
- package/package.json +1 -1
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` |
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/tools/match.js
CHANGED
|
@@ -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
|
-
|
|
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 }` —
|
|
561
|
-
* keyed by OUTCOME. `match_details sections:["playerStats"]` had no
|
|
562
|
-
* at all, so it answered `playerStats: null` while `match_summary`
|
|
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)
|
|
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}.
|
|
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: {
|
package/dist/tools/meta.js
CHANGED
|
@@ -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
|
+
"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": {
|