cito-mcp 0.3.21 → 0.4.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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-ta
12
12
 
13
13
  v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
14
14
 
15
- **v0.3 ships 18 hand-authored tools** that answer *jobs* instead of mirroring REST:
15
+ **Ships 42 curated outcome tools** that answer *jobs* instead of mirroring REST:
16
16
 
17
17
  | Job | Tool |
18
18
  | --- | --- |
@@ -28,6 +28,12 @@ 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
+ | Tennis tournament catalog | `tournaments` |
33
+ | One day of tennis | `tennis_schedule` |
34
+ | Tennis player match log | `player_matches` |
35
+ | Tennis career statistics | `player_stats` |
36
+ | Tennis ranking trajectory | `player_rankings_history` |
31
37
  | Fighter / team photos | `event_card` · `player_profile` |
32
38
  | Which raw REST route exists? | `list_routes` |
33
39
  | Name → ID | `resolve_entity` / `search_entities` |
@@ -9,6 +9,9 @@ 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
+ import { tournamentTools } from './tournaments.js';
14
+ import { scheduleTools } from './schedule.js';
12
15
  /** Curated outcome-tool catalog. Order matches preferred cold-start ladder. */
13
16
  export const allTools = [
14
17
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
@@ -20,6 +23,11 @@ export const allTools = [
20
23
  ...standingsTools,
21
24
  ...rankingsTools,
22
25
  ...leaderboardTools,
26
+ // Tennis depth added after the coverage audit: odds, tournament discovery and
27
+ // the daily schedule were reachable only through call_api.
28
+ ...oddsTools,
29
+ ...tournamentTools,
30
+ ...scheduleTools,
23
31
  ...insightTools,
24
32
  ...cs2Tools,
25
33
  // Escape-hatch pair last: discover routes, then call one.
@@ -156,6 +156,66 @@ const TOOL_CATALOG = [
156
156
  preferOver: ['raw form via call_api'],
157
157
  doNotUse: 'Career totals/titles → player_profile; ranking deltas → rankings_movers',
158
158
  },
159
+ {
160
+ name: 'player_matches',
161
+ outcome: 'Tennis player match log: every archived match with opponent, round and score',
162
+ parallelSafe: true,
163
+ games: ['tennis'],
164
+ jobs: ['player_form', 'match_page'],
165
+ exampleArgs: { game: 'tennis', playerId: 'atp_210097', limit: 20 },
166
+ preferOver: ['raw match log via call_api', 'player_form (summary window, not the log)'],
167
+ doNotUse: 'Aggregates/titles → player_stats; one box score → match_details',
168
+ },
169
+ {
170
+ name: 'player_stats',
171
+ outcome: 'Tennis career statistics: W/L, win%, titles, Grand Slam and Masters titles, breakdowns',
172
+ parallelSafe: true,
173
+ games: ['tennis'],
174
+ jobs: ['player_form'],
175
+ exampleArgs: { game: 'tennis', playerId: 'atp_207989', surface: 'Clay' },
176
+ preferOver: ['raw player stats via call_api'],
177
+ doNotUse: 'Match rows → player_matches; ranking over time → player_rankings_history',
178
+ },
179
+ {
180
+ name: 'player_rankings_history',
181
+ outcome: 'Tennis ranking trajectory per published date, plus career-high and weeks at No.1',
182
+ parallelSafe: true,
183
+ games: ['tennis'],
184
+ jobs: ['standings', 'player_form'],
185
+ exampleArgs: { game: 'tennis', playerId: 'atp_207989', since: '2026-01-01', limit: 20 },
186
+ preferOver: ['raw rankings history via call_api'],
187
+ doNotUse: 'Latest snapshot → standings; week-over-week delta → rankings_movers',
188
+ },
189
+ {
190
+ name: 'tennis_odds',
191
+ outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match',
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
+ {
200
+ name: 'tournaments',
201
+ outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country',
202
+ parallelSafe: true,
203
+ games: ['tennis'],
204
+ jobs: ['preview', 'standings'],
205
+ exampleArgs: { game: 'tennis', year: 2026, tour: 'WTA', level: 'WTA 1000', limit: 20 },
206
+ preferOver: ['raw tournament list via call_api'],
207
+ doNotUse: 'A specific draw/edition → event_card with the id this returns',
208
+ },
209
+ {
210
+ name: 'tennis_schedule',
211
+ outcome: "One day of tennis: that day's schedule and/or completed matches",
212
+ parallelSafe: true,
213
+ games: ['tennis'],
214
+ jobs: ['schedule', 'match_page'],
215
+ exampleArgs: { game: 'tennis', date: '2026-09-12', include: 'both', limit: 20 },
216
+ preferOver: ['raw schedule/completed via call_api (both require ?date=)'],
217
+ doNotUse: 'In-play right now → live_matches; a forward window → upcoming_schedule',
218
+ },
159
219
  {
160
220
  name: 'leaderboard_aces',
161
221
  outcome: 'Tennis season aces leaderboard: most aces served, ordered first',
@@ -0,0 +1,251 @@
1
+ /**
2
+ * tennis_odds — the betting-odds surface for tennis (TENNIS-24).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * The tennis API serves three odds routes, and NONE of them had a tool. Odds are
6
+ * a headline feature of the paid tiers and one of the most common reasons to buy
7
+ * a sports API, so a builder had to discover /tennis/odds/* through call_api or
8
+ * the docs. This closes that gap.
9
+ *
10
+ * Coverage is genuinely partial upstream, and the tool says so rather than
11
+ * returning an empty success: /tennis/odds/{id} answers with
12
+ * `coverage: { odds: false }` and an empty bookmakers[] for any match the feed
13
+ * has no prices for (verified against atp_2026_560, which returned exactly
14
+ * that). An agent that cannot tell "no odds exist for this match" from "the call
15
+ * failed" will invent a price, so the response distinguishes the two.
16
+ */
17
+ import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
18
+ import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
19
+ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
20
+ /** A bookmaker row: { key, title, last_update, markets: [{ key, outcomes: [{ name, price }] }] }. */
21
+ function normalizeBookmaker(row) {
22
+ const r = asRecord(row) ?? {};
23
+ const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
24
+ const m = asRecord(mk) ?? {};
25
+ const outcomes = (Array.isArray(m.outcomes) ? m.outcomes : []).map((o) => {
26
+ const oc = asRecord(o) ?? {};
27
+ return {
28
+ name: pickString(oc.name) ?? null,
29
+ // Decimal odds as the feed supplies them; never recomputed or implied.
30
+ price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
31
+ point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
32
+ };
33
+ });
34
+ return {
35
+ market: pickString(m.key) ?? null,
36
+ outcomes,
37
+ // Implied probability from the prices actually returned, so a caller does
38
+ // not have to redo the arithmetic. Null when any price is missing.
39
+ impliedProbabilities: (() => {
40
+ const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
41
+ if (prices.length !== outcomes.length || prices.length === 0)
42
+ return null;
43
+ const inv = prices.map((p) => 1 / p);
44
+ const sum = inv.reduce((a, b) => a + b, 0);
45
+ return inv.map((v) => Number((v / sum).toFixed(4)));
46
+ })(),
47
+ };
48
+ });
49
+ return {
50
+ key: pickString(r.key) ?? null,
51
+ title: pickString(r.title) ?? null,
52
+ lastUpdate: pickString(r.last_update) ?? null,
53
+ markets,
54
+ };
55
+ }
56
+ function parseTennis(args, tool) {
57
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
58
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
59
+ return { error: gameParse.error ?? 'game is required', game: null };
60
+ }
61
+ const game = gameParse.game;
62
+ if (game !== 'tennis') {
63
+ return {
64
+ error: `${tool} is tennis-only (odds for '${game}' is not served by this endpoint)`,
65
+ game,
66
+ };
67
+ }
68
+ return { game };
69
+ }
70
+ function requireTennisId(args, tool) {
71
+ const id = typeof args.matchId === 'string' ? args.matchId.trim() : '';
72
+ if (!id)
73
+ return { error: `${tool} requires matchId` };
74
+ return { matchId: id };
75
+ }
76
+ export const tennisOdds = {
77
+ name: 'tennis_odds',
78
+ description: `Tennis betting odds: upcoming matches with prices, plus pre-match and in-play odds for one match.
79
+
80
+ Modes (pick with 'scope'):
81
+ - upcoming (default): matches with odds available, each with the bookmakers quoting it.
82
+ - match: pre-match odds for one match_id.
83
+ - live: in-play odds for one match_id.
84
+
85
+ When to use:
86
+ - "What are the odds on X vs Y?"; pre-match prices; in-play prices; which matches have odds today.
87
+
88
+ Prefer over: call_api for /tennis/odds/*; match_preview (no prices).
89
+
90
+ Do not use when: the match result → match_details; rankings → standings.
91
+
92
+ Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
93
+ priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
94
+ than invented numbers, and this tool surfaces that distinction explicitly.
95
+
96
+ Parallel-safe: yes. Upstream cost: 1.`,
97
+ inputSchema: {
98
+ type: 'object',
99
+ additionalProperties: false,
100
+ required: ['game'],
101
+ properties: {
102
+ game: gameSchema({ allowAll: false, required: true }),
103
+ scope: stringSchema('upcoming (default) | match | live.', 'upcoming'),
104
+ matchId: stringSchema('Match id for scope=match or scope=live, e.g. "s365_2026_4853596".', 's365_2026_4853596'),
105
+ limit: limitSchema({ default: 20, max: 50, description: 'Rows for scope=upcoming (default 20, max 50).' }),
106
+ },
107
+ },
108
+ handler: async (args, ctx) => {
109
+ const started = Date.now();
110
+ const requestId = newRequestId();
111
+ const parsed = parseTennis(args, 'tennis_odds');
112
+ if (parsed.error) {
113
+ return errorEnvelope({
114
+ code: parsed.error.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
115
+ message: parsed.error,
116
+ game: parsed.game,
117
+ source: 'tennis_odds',
118
+ requestId,
119
+ tookMs: Date.now() - started,
120
+ });
121
+ }
122
+ const game = parsed.game;
123
+ const scopeRaw = typeof args.scope === 'string' ? args.scope.trim().toLowerCase() : 'upcoming';
124
+ if (!['upcoming', 'match', 'live'].includes(scopeRaw)) {
125
+ return errorEnvelope({
126
+ code: 'VALIDATION',
127
+ message: `scope must be upcoming, match, or live (got '${args.scope}')`,
128
+ game,
129
+ source: 'tennis_odds',
130
+ requestId,
131
+ tookMs: Date.now() - started,
132
+ recover: ['Use scope=upcoming for matches with odds', 'Use scope=match or scope=live with a matchId'],
133
+ });
134
+ }
135
+ const matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
136
+ if (scopeRaw !== 'upcoming' && !matchId) {
137
+ return errorEnvelope({
138
+ code: 'VALIDATION',
139
+ message: `matchId is required when scope=${scopeRaw}`,
140
+ game,
141
+ source: 'tennis_odds',
142
+ requestId,
143
+ tookMs: Date.now() - started,
144
+ recover: [`Pass matchId, or use scope=upcoming with no id`],
145
+ });
146
+ }
147
+ const limit = clampInt(args.limit, 20, 1, 50);
148
+ if (scopeRaw === 'upcoming') {
149
+ const res = await fetchJson(ctx, '/tennis/odds/upcoming', { query: { limit } });
150
+ if (!res.ok) {
151
+ return errorEnvelope({
152
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
153
+ message: `Upcoming tennis odds failed (HTTP ${res.status})`,
154
+ game,
155
+ source: 'tennis_odds',
156
+ requestId,
157
+ tookMs: Date.now() - started,
158
+ upstreamCalls: 1,
159
+ httpStatus: res.status,
160
+ rateLimit: res.headers,
161
+ });
162
+ }
163
+ const root = asRecord(res.data) ?? {};
164
+ const body = asRecord(root.data) ?? root;
165
+ const items = (Array.isArray(body.items) ? body.items : []).map((row) => {
166
+ const r = asRecord(row) ?? {};
167
+ const home = asRecord(r.home) ?? {};
168
+ const away = asRecord(r.away) ?? {};
169
+ return {
170
+ matchId: pickString(r.match_id, r.id) ?? null,
171
+ oddsEventId: pickString(r.odds_event_id) ?? null,
172
+ tournament: pickString(r.tournament) ?? null,
173
+ commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
174
+ player1: { name: pickString(home.name) ?? null, id: pickString(home.player_id, home.id) ?? null },
175
+ player2: { name: pickString(away.name) ?? null, id: pickString(away.player_id, away.id) ?? null },
176
+ bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
177
+ live: r.live === true,
178
+ };
179
+ });
180
+ const total = typeof body.total === 'number' ? body.total : items.length;
181
+ return successEnvelope({
182
+ pagination: {
183
+ limit,
184
+ offset: 0,
185
+ total,
186
+ hasMore: body.has_next === true,
187
+ nextCursor: null,
188
+ prevCursor: null,
189
+ },
190
+ source: 'tennis_odds',
191
+ game,
192
+ requestId,
193
+ tookMs: Date.now() - started,
194
+ upstreamCalls: 1,
195
+ rateLimit: res.headers,
196
+ data: {
197
+ title: `Tennis odds — upcoming (${items.length} of ${total})`,
198
+ scope: scopeRaw,
199
+ oddsFormat: 'decimal',
200
+ items,
201
+ total,
202
+ },
203
+ });
204
+ }
205
+ const path = scopeRaw === 'match'
206
+ ? `/tennis/odds/${encodeURIComponent(matchId)}`
207
+ : `/tennis/odds/${encodeURIComponent(matchId)}/live`;
208
+ const res = await fetchJson(ctx, path, {});
209
+ if (!res.ok) {
210
+ return errorEnvelope({
211
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
212
+ message: `Tennis ${scopeRaw} odds failed for '${matchId}' (HTTP ${res.status})`,
213
+ game,
214
+ source: 'tennis_odds',
215
+ requestId,
216
+ tookMs: Date.now() - started,
217
+ upstreamCalls: 1,
218
+ httpStatus: res.status,
219
+ rateLimit: res.headers,
220
+ recover: ['Check the match id with live_matches or match_details'],
221
+ });
222
+ }
223
+ const root = asRecord(res.data) ?? {};
224
+ const body = asRecord(root.data) ?? root;
225
+ const coverage = asRecord(body.coverage) ?? {};
226
+ const bookmakers = (Array.isArray(body.bookmakers) ? body.bookmakers : []).map(normalizeBookmaker);
227
+ const hasOdds = coverage.odds === true || bookmakers.length > 0;
228
+ return successEnvelope({
229
+ source: 'tennis_odds',
230
+ game,
231
+ requestId,
232
+ tookMs: Date.now() - started,
233
+ upstreamCalls: 1,
234
+ rateLimit: res.headers,
235
+ data: {
236
+ title: `Tennis ${scopeRaw} odds — ${matchId}`,
237
+ scope: scopeRaw,
238
+ matchId: pickString(body.match_id) ?? matchId,
239
+ oddsFormat: pickString(body.odds_format) ?? 'decimal',
240
+ bookmakers,
241
+ // Explicit, because an empty bookmakers[] is otherwise indistinguishable
242
+ // from a failed scrape.
243
+ oddsAvailable: hasOdds,
244
+ note: hasOdds
245
+ ? null
246
+ : 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
247
+ },
248
+ });
249
+ },
250
+ };
251
+ export const oddsTools = [tennisOdds];