cito-mcp 0.3.20 → 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 +7 -1
- package/dist/tools/cs2.js +421 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/leaderboard.js +427 -0
- package/dist/tools/meta.js +241 -1
- package/dist/tools/odds.js +251 -0
- package/dist/tools/player.js +340 -1
- package/dist/tools/rankings.js +147 -1
- package/dist/tools/schedule.js +234 -0
- package/dist/tools/team.js +183 -1
- package/dist/tools/tournaments.js +253 -0
- package/package.json +2 -2
package/dist/tools/meta.js
CHANGED
|
@@ -116,6 +116,16 @@ const TOOL_CATALOG = [
|
|
|
116
116
|
preferOver: ['agent-side double match-list filtering'],
|
|
117
117
|
doNotUse: 'Single-side form only → team_profile or player_profile',
|
|
118
118
|
},
|
|
119
|
+
{
|
|
120
|
+
name: 'h2h_matrix',
|
|
121
|
+
outcome: 'Tennis multi-player H2H grid: every pair series record in one matrix',
|
|
122
|
+
parallelSafe: true,
|
|
123
|
+
games: ['tennis'],
|
|
124
|
+
jobs: ['h2h', 'preview'],
|
|
125
|
+
exampleArgs: { game: 'tennis', players: ['atp_207989', 'atp_100644'] },
|
|
126
|
+
preferOver: ['N head_to_head calls for an N-player field', 'raw matrix via call_api'],
|
|
127
|
+
doNotUse: 'Two-player deep-dive with tiebreak splits → head_to_head; season leaders → leaderboard_*',
|
|
128
|
+
},
|
|
119
129
|
{
|
|
120
130
|
name: 'standings',
|
|
121
131
|
outcome: 'League/event standings or world/division rankings',
|
|
@@ -146,6 +156,156 @@ const TOOL_CATALOG = [
|
|
|
146
156
|
preferOver: ['raw form via call_api'],
|
|
147
157
|
doNotUse: 'Career totals/titles → player_profile; ranking deltas → rankings_movers',
|
|
148
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
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'leaderboard_aces',
|
|
221
|
+
outcome: 'Tennis season aces leaderboard: most aces served, ordered first',
|
|
222
|
+
parallelSafe: true,
|
|
223
|
+
games: ['tennis'],
|
|
224
|
+
jobs: ['standings'],
|
|
225
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
226
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
227
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'leaderboard_break_points_saved',
|
|
231
|
+
outcome: 'Tennis season break-points-saved pct leaderboard, min 50 faced',
|
|
232
|
+
parallelSafe: true,
|
|
233
|
+
games: ['tennis'],
|
|
234
|
+
jobs: ['standings'],
|
|
235
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
236
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
237
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'leaderboard_first_serve_won',
|
|
241
|
+
outcome: 'Tennis season first-serve-won pct leaderboard, min 300 in',
|
|
242
|
+
parallelSafe: true,
|
|
243
|
+
games: ['tennis'],
|
|
244
|
+
jobs: ['standings'],
|
|
245
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
246
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
247
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'leaderboard_tiebreak_win_pct',
|
|
251
|
+
outcome: 'Tennis season tiebreak-win-pct leaderboard, min 10 tiebreaks',
|
|
252
|
+
parallelSafe: true,
|
|
253
|
+
games: ['tennis'],
|
|
254
|
+
jobs: ['standings'],
|
|
255
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
256
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
257
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'leaderboard_break_conversion',
|
|
261
|
+
outcome: 'Tennis season break-conversion pct leaderboard, min 50 opportunities',
|
|
262
|
+
parallelSafe: true,
|
|
263
|
+
games: ['tennis'],
|
|
264
|
+
jobs: ['standings'],
|
|
265
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
266
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
267
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'leaderboard_return_games_won',
|
|
271
|
+
outcome: 'Tennis season return-games-won pct leaderboard, min 100 played',
|
|
272
|
+
parallelSafe: true,
|
|
273
|
+
games: ['tennis'],
|
|
274
|
+
jobs: ['standings'],
|
|
275
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
276
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
277
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'leaderboard_deciding_set_record',
|
|
281
|
+
outcome: 'Tennis season deciding-set-record pct leaderboard, min 10 deciders',
|
|
282
|
+
parallelSafe: true,
|
|
283
|
+
games: ['tennis'],
|
|
284
|
+
jobs: ['standings'],
|
|
285
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
286
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
287
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'leaderboard_comeback_wins',
|
|
291
|
+
outcome: 'Tennis season comeback-wins pct leaderboard, min 20 first sets',
|
|
292
|
+
parallelSafe: true,
|
|
293
|
+
games: ['tennis'],
|
|
294
|
+
jobs: ['standings'],
|
|
295
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
296
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
297
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'leaderboard_finals_record',
|
|
301
|
+
outcome: 'Tennis season finals-record pct leaderboard, min 3 finals',
|
|
302
|
+
parallelSafe: true,
|
|
303
|
+
games: ['tennis'],
|
|
304
|
+
jobs: ['standings'],
|
|
305
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
306
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
307
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
308
|
+
},
|
|
149
309
|
{
|
|
150
310
|
name: 'match_preview',
|
|
151
311
|
outcome: 'Pre-match briefing: sides, rosters/form, H2H stub',
|
|
@@ -172,6 +332,86 @@ const TOOL_CATALOG = [
|
|
|
172
332
|
],
|
|
173
333
|
doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
|
|
174
334
|
},
|
|
335
|
+
{
|
|
336
|
+
name: 'cs2_live_scoreboard',
|
|
337
|
+
outcome: 'Live real-time in-game scoreboard for an active CS2 match',
|
|
338
|
+
parallelSafe: true,
|
|
339
|
+
games: ['cs2'],
|
|
340
|
+
jobs: ['live_board', 'match_page'],
|
|
341
|
+
exampleArgs: { matchId: 'cs2-match-2397733' },
|
|
342
|
+
preferOver: ['polling call_api for live scoreboard'],
|
|
343
|
+
doNotUse: 'For completed matches without live state',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'cs2_round_economy',
|
|
347
|
+
outcome: 'Round-by-round tactical economy and buy types for a CS2 map',
|
|
348
|
+
parallelSafe: true,
|
|
349
|
+
games: ['cs2'],
|
|
350
|
+
jobs: ['match_page'],
|
|
351
|
+
exampleArgs: { mapId: '7716-map-1' },
|
|
352
|
+
preferOver: ['raw round event parsing'],
|
|
353
|
+
doNotUse: 'For overall match scorelines → match_summary',
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'cs2_opening_duels',
|
|
357
|
+
outcome: 'First blood and opening duel statistics by player or leaderboard',
|
|
358
|
+
parallelSafe: true,
|
|
359
|
+
games: ['cs2'],
|
|
360
|
+
jobs: ['player_form', 'match_page'],
|
|
361
|
+
exampleArgs: { playerId: 'chucky', limit: 20 },
|
|
362
|
+
preferOver: ['manual kill log counting'],
|
|
363
|
+
doNotUse: 'For general player stats → player_profile',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'cs2_clutches',
|
|
367
|
+
outcome: '1vX clutch situation success records (1v1 to 1v5)',
|
|
368
|
+
parallelSafe: true,
|
|
369
|
+
games: ['cs2'],
|
|
370
|
+
jobs: ['player_form', 'match_page'],
|
|
371
|
+
exampleArgs: { playerId: 'xm1nd', limit: 20 },
|
|
372
|
+
preferOver: ['manual demo parse analysis'],
|
|
373
|
+
doNotUse: 'For regular player stats → player_profile',
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'cs2_team_map_stats',
|
|
377
|
+
outcome: 'Team win rates and CT/T side splits across competitive map pool',
|
|
378
|
+
parallelSafe: true,
|
|
379
|
+
games: ['cs2'],
|
|
380
|
+
jobs: ['team_page', 'preview'],
|
|
381
|
+
exampleArgs: { teamId: 'hltv-team-4608', days: 90 },
|
|
382
|
+
preferOver: ['manual match history scraping'],
|
|
383
|
+
doNotUse: 'For non-CS2 games',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: 'cs2_utility_leaderboard',
|
|
387
|
+
outcome: 'Sub-second leaderboard of CS2 utility and flashbang efficiency',
|
|
388
|
+
parallelSafe: true,
|
|
389
|
+
games: ['cs2'],
|
|
390
|
+
jobs: ['standings', 'player_form'],
|
|
391
|
+
exampleArgs: { limit: 20 },
|
|
392
|
+
preferOver: ['unindexed round event aggregation'],
|
|
393
|
+
doNotUse: 'For standard weapon ratings',
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'cs2_veto_sequence',
|
|
397
|
+
outcome: 'Map pick/ban sequence and veto history for a CS2 match',
|
|
398
|
+
parallelSafe: true,
|
|
399
|
+
games: ['cs2'],
|
|
400
|
+
jobs: ['match_page', 'preview'],
|
|
401
|
+
exampleArgs: { matchId: '2397603' },
|
|
402
|
+
preferOver: ['scraping match page text'],
|
|
403
|
+
doNotUse: 'For map scores → match_summary',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: 'cs2_roster_transfers',
|
|
407
|
+
outcome: 'Recent pro CS2 roster changes, benchings, stand-ins, and transfers',
|
|
408
|
+
parallelSafe: true,
|
|
409
|
+
games: ['cs2'],
|
|
410
|
+
jobs: ['team_page', 'schedule'],
|
|
411
|
+
exampleArgs: { limit: 20 },
|
|
412
|
+
preferOver: ['manual news feed scanning'],
|
|
413
|
+
doNotUse: 'For current active rosters → team_profile',
|
|
414
|
+
},
|
|
175
415
|
{
|
|
176
416
|
name: 'list_routes',
|
|
177
417
|
outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
|
|
@@ -199,7 +439,7 @@ const JOBS = [
|
|
|
199
439
|
{ id: 'team_page', description: 'Team/org profile screen', recommendedTools: ['resolve_entity', 'team_profile'] },
|
|
200
440
|
{ id: 'player_form', description: 'Player/fighter form card', recommendedTools: ['resolve_entity', 'player_profile'] },
|
|
201
441
|
{ id: 'standings', description: 'Tables and rankings', recommendedTools: ['standings'] },
|
|
202
|
-
{ id: 'h2h', description: 'Historical rivalry record', recommendedTools: ['head_to_head'] },
|
|
442
|
+
{ id: 'h2h', description: 'Historical rivalry record', recommendedTools: ['head_to_head', 'h2h_matrix'] },
|
|
203
443
|
{ id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
|
|
204
444
|
{ id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
|
|
205
445
|
{ id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
|
|
@@ -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];
|