cito-mcp 0.4.6 → 0.4.7
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 +5 -6
- package/dist/tools/match.js +1 -1
- package/dist/tools/meta.js +53 -53
- package/package.json +1 -1
- package/dist/tools/odds.js +0 -287
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
|
|
4
4
|
|
|
5
|
-
**Version:** `0.
|
|
5
|
+
**Version:** `0.4.7` · **Node:** `>=20` · **Install:** `npx cito-mcp`
|
|
6
6
|
|
|
7
7
|
Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
|
|
8
8
|
|
|
@@ -50,8 +50,6 @@ Where the source data is partial, the tool says so instead of shipping a plausib
|
|
|
50
50
|
| The two ranking lists compared by `rankings_movers` are not a week apart | `gapDays` + `comparisonWindow` (`"12-week gap"`), `comparisonsAreWeekly: false`, and a warning naming both dates |
|
|
51
51
|
| A tennis tournament draw is missing matches | `draw.shortRounds` (`["R128 45/64"]`), `draw.missingRounds`, `draw.complete` |
|
|
52
52
|
| A stats section the endpoint does not publish | `player_stats.unavailable` names the section and why, rather than a block of nulls |
|
|
53
|
-
| No bookmaker is quoting a match | `oddsAvailable: false` plus a note that it is a coverage gap, not an error |
|
|
54
|
-
| The odds feed leaves `match_id` null | `joinKey` (`{kind:"oddsEventId", …}`) and `playerIds`, so the row is still joinable |
|
|
55
53
|
| `search_entities` results that do not match at all | dropped, counted under `data.relevance.droppedBelowFloor`, with a warning naming them |
|
|
56
54
|
|
|
57
55
|
---
|
|
@@ -542,11 +540,14 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
|
|
|
542
540
|
|
|
543
541
|
## Changelog (summary)
|
|
544
542
|
|
|
543
|
+
### 0.4.7
|
|
544
|
+
|
|
545
|
+
- Removed the unregistered tennis odds module and its README entries; `match_details` `sections:["odds"]` stays UFC-only and answers `NOT_IMPLEMENTED` for other games.
|
|
546
|
+
|
|
545
547
|
### 0.4.2
|
|
546
548
|
|
|
547
549
|
Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each reproduced from a raw payload and re-verified after the fix. Two were upstream (fixed on the API and verified with the 40/40 invariant gate); three were MCP-side.
|
|
548
550
|
|
|
549
|
-
- **`tennis_odds` called a nonexistent match "no coverage".** `/tennis/odds/{id}` answers **HTTP 200 with `coverage.odds=false` for every id it does not know** — verified for `s365_2026_0000000` and for the literal `total-garbage-id`. So a typo and a genuine no-book gap were indistinguishable and both came back `ok:true`. `scope=match`/`live` now verify the id against `/tennis/matches/{id}` (which does 404) when no odds are quoted: unknown id → `NOT_FOUND` with recovery steps, real match → an honest coverage gap with `matchExists: true`.
|
|
550
551
|
- **Tennis `standings` published a fake total and could not be paged.** `/tennis/standings?top_n=5` set `rank_max=top_n`, so the count (which carried the rank bounds) equalled the page size: `total:5`, `hasMore:false` for a list of **150** ranked players. The API now counts the population without the rank window and returns `ranked_players`/`total_pages`/`has_next`, `/standings` takes a `page` argument, and the tool reports `total`/`rankedPlayers`/`totalPages`/`hasMore` — page 2 of the ATP list now starts at rank 6. The same `allRows.length` mistake in the `pagination` block was fixed too, so the two counts cannot disagree.
|
|
551
552
|
- **`event_card` published the requested id as the event name.** With a bad id it answered `ok:true` and `event.name = "atp_2026_999999"` over a 404 that `partial[]` reported correctly. Names now come from the payload or are `null`, with `event.resolved` recording whether the detail fetch succeeded.
|
|
552
553
|
- **`leaderboard_*` reported the page size as the population.** Every board returned `"total": len(items)` after applying `.limit(limit)`, so `limit=2` answered `total:2` — for boards whose own docs record thousands of qualifiers. Upstream now carries `count(*) OVER ()` (Postgres evaluates it after `GROUP BY`/`HAVING`, so it is exactly the qualifying population, at no extra round trip): aces **2488**, break-conversion **1089** (matching the figure in its own docstring), 1st-serve-won 1141, bp-saved 1186, return-games 987, tiebreak 364, deciders 334, comebacks 611, finals 66. Each tool also reports `returned` and the board's `minimumAttempts`.
|
|
@@ -560,12 +561,10 @@ Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each
|
|
|
560
561
|
Tennis correctness pass, driven by the 2026-09-12 sweep (`reports/tennis-mcp-sweep-2026-09-12.md`). Every item was reproduced against live ids and re-verified after the fix.
|
|
561
562
|
|
|
562
563
|
- **`match_summary` tennis scoreline was always `- : -`.** `/tennis/matches/{id}` puts no `sets_won` on its player objects, so the sets-won score was null for every completed match while the same payload held the real score in `score` and `sets[]`. Sets won are now derived (explicit → `winner_sets_won` → `sets[]` → `score` string), `score.detail` carries the game score, and a set's `completed` is inferred when the archive omits `is_completed`. The winner-oriented `winner_games`/`loser_games` pair is deliberately not used as a side score. `bestOf` now reads `best_of`.
|
|
563
|
-
- **`match_details` denied tennis odds existed.** The `odds` section was gated to UFC; the same match returned bookmaker prices from `tennis_odds`. Both surfaces now share one projection (`summarizeTennisOdds`), and `playerStats` (previously always `null` for tennis) is filled from `/tennis/matches/{id}/stats`.
|
|
564
564
|
- **`player_stats.bySurface` was always four nulls.** The endpoint publishes one object per surface and the projector coerced each to a number. Real per-surface blocks, `servingStats`, and an explicit `unavailable` map for sections the endpoint does not publish.
|
|
565
565
|
- **`limit` was ignored** by `player_rankings_history` (asked 4, got 342) and by `/tennis/matches/completed`. Both are bounded locally now, with `*Returned` versus `*Count` so the day total is never confused with the page.
|
|
566
566
|
- **`tournaments level="WTA 1000"` returned ATP Masters events.** The API's level reverse-map is not tour-aware (`_TIER_WTA` maps both `PM` and `M` to "WTA 1000"), so the tool now sends and enforces the tour the level name states, and drops + counts contradicting rows. Fixed at the source too — the same widening also leaked `WTA 125`, `Challenger`, `Davis Cup` and `Billie Jean King Cup`.
|
|
567
567
|
- **`rankings_movers` called an 84-day gap "week-over-week".** `gapDays`/`comparisonWindow`/`comparisonsAreWeekly` now state the real interval, with a warning. `standings` reports the tennis `ranking_date` as `updatedAt` instead of `null`.
|
|
568
|
-
- **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
|
|
569
568
|
- **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
|
|
570
569
|
- **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
|
|
571
570
|
- **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
|
package/dist/tools/match.js
CHANGED
|
@@ -809,7 +809,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
809
809
|
else {
|
|
810
810
|
partial.push(partialFromRejection('odds', {
|
|
811
811
|
code: 'NOT_IMPLEMENTED',
|
|
812
|
-
message: `Odds are
|
|
812
|
+
message: `Odds are only available for UFC (/ufc/bouts/{id}/odds).`,
|
|
813
813
|
}));
|
|
814
814
|
}
|
|
815
815
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -488,18 +488,18 @@ const GAMES_META = [
|
|
|
488
488
|
];
|
|
489
489
|
export const listCapabilities = {
|
|
490
490
|
name: 'list_capabilities',
|
|
491
|
-
description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
|
|
492
|
-
|
|
493
|
-
When to use:
|
|
494
|
-
- Session start or "what can you do?"
|
|
495
|
-
- Mapping app screens to tools
|
|
496
|
-
- Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
|
|
497
|
-
|
|
498
|
-
Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
|
|
499
|
-
|
|
500
|
-
Do not use when: you already know the tool and have IDs — call that tool directly.
|
|
501
|
-
|
|
502
|
-
Parallel-safe: yes. Upstream cost: 0.
|
|
491
|
+
description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
|
|
492
|
+
|
|
493
|
+
When to use:
|
|
494
|
+
- Session start or "what can you do?"
|
|
495
|
+
- Mapping app screens to tools
|
|
496
|
+
- Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
|
|
497
|
+
|
|
498
|
+
Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
|
|
499
|
+
|
|
500
|
+
Do not use when: you already know the tool and have IDs — call that tool directly.
|
|
501
|
+
|
|
502
|
+
Parallel-safe: yes. Upstream cost: 0.
|
|
503
503
|
Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
|
|
504
504
|
inputSchema: {
|
|
505
505
|
type: 'object',
|
|
@@ -582,18 +582,18 @@ Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
|
|
|
582
582
|
};
|
|
583
583
|
export const apiHealth = {
|
|
584
584
|
name: 'api_health',
|
|
585
|
-
description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
|
|
586
|
-
|
|
587
|
-
When to use:
|
|
588
|
-
- Once per session before heavy work
|
|
589
|
-
- After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
|
|
590
|
-
- App scaffolding entitlement checks
|
|
591
|
-
|
|
592
|
-
Prefer over: probing random game endpoints to test the key.
|
|
593
|
-
|
|
594
|
-
Do not use when: you only need live scores — use live_matches.
|
|
595
|
-
|
|
596
|
-
Parallel-safe: yes. Upstream cost: 1–6.
|
|
585
|
+
description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
|
|
586
|
+
|
|
587
|
+
When to use:
|
|
588
|
+
- Once per session before heavy work
|
|
589
|
+
- After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
|
|
590
|
+
- App scaffolding entitlement checks
|
|
591
|
+
|
|
592
|
+
Prefer over: probing random game endpoints to test the key.
|
|
593
|
+
|
|
594
|
+
Do not use when: you only need live scores — use live_matches.
|
|
595
|
+
|
|
596
|
+
Parallel-safe: yes. Upstream cost: 1–6.
|
|
597
597
|
Example: { "includeGameProbes": true }`,
|
|
598
598
|
inputSchema: {
|
|
599
599
|
type: 'object',
|
|
@@ -736,21 +736,21 @@ function pathAllowed(path) {
|
|
|
736
736
|
}
|
|
737
737
|
export const callApi = {
|
|
738
738
|
name: 'call_api',
|
|
739
|
-
description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
|
|
740
|
-
|
|
741
|
-
When to use:
|
|
742
|
-
- Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
|
|
743
|
-
- Debugging payloads while building an app
|
|
744
|
-
- User explicitly knows an OpenAPI path
|
|
745
|
-
|
|
746
|
-
Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
|
|
747
|
-
|
|
748
|
-
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
749
|
-
|
|
750
|
-
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
|
|
751
|
-
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
752
|
-
|
|
753
|
-
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
739
|
+
description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
|
|
740
|
+
|
|
741
|
+
When to use:
|
|
742
|
+
- Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
|
|
743
|
+
- Debugging payloads while building an app
|
|
744
|
+
- User explicitly knows an OpenAPI path
|
|
745
|
+
|
|
746
|
+
Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
|
|
747
|
+
|
|
748
|
+
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
749
|
+
|
|
750
|
+
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
|
|
751
|
+
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
752
|
+
|
|
753
|
+
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
754
754
|
Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"page\\":1,\\"limit\\":20}" }`,
|
|
755
755
|
inputSchema: {
|
|
756
756
|
type: 'object',
|
|
@@ -872,20 +872,20 @@ const SPEC_OMITS = {
|
|
|
872
872
|
};
|
|
873
873
|
export const listRoutes = {
|
|
874
874
|
name: 'list_routes',
|
|
875
|
-
description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
|
|
876
|
-
|
|
877
|
-
When to use:
|
|
878
|
-
- You need a long-tail path for call_api and do not want to guess
|
|
879
|
-
- Checking whether an endpoint exists before building around it
|
|
880
|
-
- Mapping what raw data backs a curated tool
|
|
881
|
-
|
|
882
|
-
Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
|
|
883
|
-
|
|
884
|
-
Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
|
|
885
|
-
|
|
886
|
-
Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
|
|
887
|
-
|
|
888
|
-
Parallel-safe: yes. Upstream cost: 1.
|
|
875
|
+
description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
|
|
876
|
+
|
|
877
|
+
When to use:
|
|
878
|
+
- You need a long-tail path for call_api and do not want to guess
|
|
879
|
+
- Checking whether an endpoint exists before building around it
|
|
880
|
+
- Mapping what raw data backs a curated tool
|
|
881
|
+
|
|
882
|
+
Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
|
|
883
|
+
|
|
884
|
+
Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
|
|
885
|
+
|
|
886
|
+
Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
|
|
887
|
+
|
|
888
|
+
Parallel-safe: yes. Upstream cost: 1.
|
|
889
889
|
Example: { "game": "ufc", "q": "rankings" }`,
|
|
890
890
|
inputSchema: {
|
|
891
891
|
type: 'object',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://cito.gg",
|
package/dist/tools/odds.js
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
2
|
-
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
3
|
-
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
4
|
-
function normalizeBookmaker(row) {
|
|
5
|
-
const r = asRecord(row) ?? {};
|
|
6
|
-
const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
|
|
7
|
-
const m = asRecord(mk) ?? {};
|
|
8
|
-
const outcomes = (Array.isArray(m.outcomes) ? m.outcomes : []).map((o) => {
|
|
9
|
-
const oc = asRecord(o) ?? {};
|
|
10
|
-
return {
|
|
11
|
-
name: pickString(oc.name) ?? null,
|
|
12
|
-
price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
|
|
13
|
-
point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
|
|
14
|
-
};
|
|
15
|
-
});
|
|
16
|
-
return {
|
|
17
|
-
market: pickString(m.key) ?? null,
|
|
18
|
-
outcomes,
|
|
19
|
-
impliedProbabilities: (() => {
|
|
20
|
-
const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
|
|
21
|
-
if (prices.length !== outcomes.length || prices.length === 0)
|
|
22
|
-
return null;
|
|
23
|
-
const inv = prices.map((p) => 1 / p);
|
|
24
|
-
const sum = inv.reduce((a, b) => a + b, 0);
|
|
25
|
-
return inv.map((v) => Number((v / sum).toFixed(4)));
|
|
26
|
-
})(),
|
|
27
|
-
};
|
|
28
|
-
});
|
|
29
|
-
return {
|
|
30
|
-
key: pickString(r.key) ?? null,
|
|
31
|
-
title: pickString(r.title) ?? null,
|
|
32
|
-
lastUpdate: pickString(r.last_update) ?? null,
|
|
33
|
-
markets,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
export function summarizeTennisOdds(data) {
|
|
37
|
-
const root = asRecord(data) ?? {};
|
|
38
|
-
const payload = asRecord(root.data) ?? root;
|
|
39
|
-
const coverage = asRecord(payload.coverage) ?? {};
|
|
40
|
-
const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
|
|
41
|
-
const hasOdds = coverage.odds === true || bookmakers.length > 0;
|
|
42
|
-
return {
|
|
43
|
-
matchId: pickString(payload.match_id) ?? null,
|
|
44
|
-
oddsFormat: pickString(payload.odds_format) ?? 'decimal',
|
|
45
|
-
commenceTime: pickString(payload.commence_time) ?? null,
|
|
46
|
-
stale: payload.stale === true,
|
|
47
|
-
bookmakers,
|
|
48
|
-
oddsAvailable: hasOdds,
|
|
49
|
-
note: hasOdds
|
|
50
|
-
? null
|
|
51
|
-
: 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
|
|
52
|
-
fullBookPath: '/tennis/odds/{matchId}',
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
function parseTennis(args, tool) {
|
|
56
|
-
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
57
|
-
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
58
|
-
return { error: gameParse.error ?? 'game is required', game: null };
|
|
59
|
-
}
|
|
60
|
-
const game = gameParse.game;
|
|
61
|
-
if (game !== 'tennis') {
|
|
62
|
-
return {
|
|
63
|
-
error: `${tool} is tennis-only (odds for '${game}' is not served by this endpoint)`,
|
|
64
|
-
game,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
return { game };
|
|
68
|
-
}
|
|
69
|
-
function requireTennisId(args, tool) {
|
|
70
|
-
const id = typeof args.matchId === 'string' ? args.matchId.trim() : '';
|
|
71
|
-
if (!id)
|
|
72
|
-
return { error: `${tool} requires matchId` };
|
|
73
|
-
return { matchId: id };
|
|
74
|
-
}
|
|
75
|
-
export const tennisOdds = {
|
|
76
|
-
name: 'tennis_odds',
|
|
77
|
-
description: `Tennis betting odds: upcoming matches with prices, plus pre-match and in-play odds for one match.
|
|
78
|
-
|
|
79
|
-
Modes (pick with 'scope'):
|
|
80
|
-
- upcoming (default): matches with odds available, each with the bookmakers quoting it.
|
|
81
|
-
- match: pre-match odds for one match_id.
|
|
82
|
-
- live: in-play odds for one match_id.
|
|
83
|
-
|
|
84
|
-
When to use:
|
|
85
|
-
- "What are the odds on X vs Y?"; pre-match prices; in-play prices; which matches have odds today.
|
|
86
|
-
|
|
87
|
-
Prefer over: call_api for /tennis/odds/*; match_preview (no prices).
|
|
88
|
-
|
|
89
|
-
Do not use when: the match result → match_details; rankings → standings.
|
|
90
|
-
|
|
91
|
-
Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
|
|
92
|
-
priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
|
|
93
|
-
than invented numbers, and this tool surfaces that distinction explicitly.
|
|
94
|
-
A match id that does not exist is a NOT_FOUND error, not a coverage gap: the
|
|
95
|
-
odds route answers 200/odds:false for every unknown id, so scope=match and
|
|
96
|
-
scope=live verify the match against /tennis/matches/{id} before reporting a gap,
|
|
97
|
-
and set matchExists.
|
|
98
|
-
|
|
99
|
-
Parallel-safe: yes. Upstream cost: 1.`,
|
|
100
|
-
inputSchema: {
|
|
101
|
-
type: 'object',
|
|
102
|
-
additionalProperties: false,
|
|
103
|
-
required: ['game'],
|
|
104
|
-
properties: {
|
|
105
|
-
game: gameSchema({ allowAll: false, required: true }),
|
|
106
|
-
scope: stringSchema('upcoming (default) | match | live.', 'upcoming'),
|
|
107
|
-
matchId: stringSchema('Match id for scope=match or scope=live, e.g. "s365_2026_4853596".', 's365_2026_4853596'),
|
|
108
|
-
limit: limitSchema({ default: 20, max: 50, description: 'Rows for scope=upcoming (default 20, max 50).' }),
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
handler: async (args, ctx) => {
|
|
112
|
-
const started = Date.now();
|
|
113
|
-
const requestId = newRequestId();
|
|
114
|
-
const parsed = parseTennis(args, 'tennis_odds');
|
|
115
|
-
if (parsed.error) {
|
|
116
|
-
return errorEnvelope({
|
|
117
|
-
code: parsed.error.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
118
|
-
message: parsed.error,
|
|
119
|
-
game: parsed.game,
|
|
120
|
-
source: 'tennis_odds',
|
|
121
|
-
requestId,
|
|
122
|
-
tookMs: Date.now() - started,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
const game = parsed.game;
|
|
126
|
-
const scopeRaw = typeof args.scope === 'string' ? args.scope.trim().toLowerCase() : 'upcoming';
|
|
127
|
-
if (!['upcoming', 'match', 'live'].includes(scopeRaw)) {
|
|
128
|
-
return errorEnvelope({
|
|
129
|
-
code: 'VALIDATION',
|
|
130
|
-
message: `scope must be upcoming, match, or live (got '${args.scope}')`,
|
|
131
|
-
game,
|
|
132
|
-
source: 'tennis_odds',
|
|
133
|
-
requestId,
|
|
134
|
-
tookMs: Date.now() - started,
|
|
135
|
-
recover: ['Use scope=upcoming for matches with odds', 'Use scope=match or scope=live with a matchId'],
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
const matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
|
|
139
|
-
if (scopeRaw !== 'upcoming' && !matchId) {
|
|
140
|
-
return errorEnvelope({
|
|
141
|
-
code: 'VALIDATION',
|
|
142
|
-
message: `matchId is required when scope=${scopeRaw}`,
|
|
143
|
-
game,
|
|
144
|
-
source: 'tennis_odds',
|
|
145
|
-
requestId,
|
|
146
|
-
tookMs: Date.now() - started,
|
|
147
|
-
recover: [`Pass matchId, or use scope=upcoming with no id`],
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
const limit = clampInt(args.limit, 20, 1, 50);
|
|
151
|
-
if (scopeRaw === 'upcoming') {
|
|
152
|
-
const res = await fetchJson(ctx, '/tennis/odds/upcoming', { query: { limit } });
|
|
153
|
-
if (!res.ok) {
|
|
154
|
-
return errorEnvelope({
|
|
155
|
-
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
156
|
-
message: `Upcoming tennis odds failed (HTTP ${res.status})`,
|
|
157
|
-
game,
|
|
158
|
-
source: 'tennis_odds',
|
|
159
|
-
requestId,
|
|
160
|
-
tookMs: Date.now() - started,
|
|
161
|
-
upstreamCalls: 1,
|
|
162
|
-
httpStatus: res.status,
|
|
163
|
-
rateLimit: res.headers,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
const root = asRecord(res.data) ?? {};
|
|
167
|
-
const body = asRecord(root.data) ?? root;
|
|
168
|
-
const items = (Array.isArray(body.items) ? body.items : []).map((row) => {
|
|
169
|
-
const r = asRecord(row) ?? {};
|
|
170
|
-
const home = asRecord(r.home) ?? {};
|
|
171
|
-
const away = asRecord(r.away) ?? {};
|
|
172
|
-
const homeId = pickString(home.player_id, home.id) ?? null;
|
|
173
|
-
const awayId = pickString(away.player_id, away.id) ?? null;
|
|
174
|
-
const matchId = pickString(r.match_id, r.id) ?? null;
|
|
175
|
-
const oddsEventId = pickString(r.odds_event_id) ?? null;
|
|
176
|
-
return {
|
|
177
|
-
matchId,
|
|
178
|
-
oddsEventId,
|
|
179
|
-
tournament: pickString(r.tournament) ?? null,
|
|
180
|
-
commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
|
|
181
|
-
player1: { name: pickString(home.name) ?? null, id: homeId },
|
|
182
|
-
player2: { name: pickString(away.name) ?? null, id: awayId },
|
|
183
|
-
bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
|
|
184
|
-
live: r.live === true,
|
|
185
|
-
joinKey: matchId
|
|
186
|
-
? { kind: 'matchId', matchId }
|
|
187
|
-
: oddsEventId
|
|
188
|
-
? { kind: 'oddsEventId', oddsEventId }
|
|
189
|
-
: null,
|
|
190
|
-
playerIds: [homeId, awayId].filter((x) => Boolean(x)),
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
194
|
-
return successEnvelope({
|
|
195
|
-
pagination: {
|
|
196
|
-
limit,
|
|
197
|
-
offset: 0,
|
|
198
|
-
total,
|
|
199
|
-
hasMore: body.has_next === true,
|
|
200
|
-
nextCursor: null,
|
|
201
|
-
prevCursor: null,
|
|
202
|
-
},
|
|
203
|
-
source: 'tennis_odds',
|
|
204
|
-
game,
|
|
205
|
-
requestId,
|
|
206
|
-
tookMs: Date.now() - started,
|
|
207
|
-
upstreamCalls: 1,
|
|
208
|
-
rateLimit: res.headers,
|
|
209
|
-
data: {
|
|
210
|
-
title: `Tennis odds — upcoming (${items.length} of ${total})`,
|
|
211
|
-
scope: scopeRaw,
|
|
212
|
-
oddsFormat: 'decimal',
|
|
213
|
-
items,
|
|
214
|
-
total,
|
|
215
|
-
},
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
const path = scopeRaw === 'match'
|
|
219
|
-
? `/tennis/odds/${encodeURIComponent(matchId)}`
|
|
220
|
-
: `/tennis/odds/${encodeURIComponent(matchId)}/live`;
|
|
221
|
-
const res = await fetchJson(ctx, path, {});
|
|
222
|
-
let upstreamCalls = 1;
|
|
223
|
-
let rateLimit = res.headers;
|
|
224
|
-
if (!res.ok) {
|
|
225
|
-
return errorEnvelope({
|
|
226
|
-
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
227
|
-
message: `Tennis ${scopeRaw} odds failed for '${matchId}' (HTTP ${res.status})`,
|
|
228
|
-
game,
|
|
229
|
-
source: 'tennis_odds',
|
|
230
|
-
requestId,
|
|
231
|
-
tookMs: Date.now() - started,
|
|
232
|
-
upstreamCalls: 1,
|
|
233
|
-
httpStatus: res.status,
|
|
234
|
-
rateLimit: res.headers,
|
|
235
|
-
recover: ['Check the match id with live_matches or match_details'],
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
const summary = summarizeTennisOdds(res.data);
|
|
239
|
-
let matchExists = null;
|
|
240
|
-
if (summary.oddsAvailable !== true) {
|
|
241
|
-
const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
|
|
242
|
-
upstreamCalls += 1;
|
|
243
|
-
rateLimit = { ...rateLimit, ...probe.headers };
|
|
244
|
-
matchExists = probe.ok;
|
|
245
|
-
if (!matchExists) {
|
|
246
|
-
const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
|
|
247
|
-
upstreamCalls += 1;
|
|
248
|
-
rateLimit = { ...rateLimit, ...liveProbe.headers };
|
|
249
|
-
matchExists = liveProbe.ok;
|
|
250
|
-
}
|
|
251
|
-
if (!matchExists) {
|
|
252
|
-
return errorEnvelope({
|
|
253
|
-
code: 'NOT_FOUND',
|
|
254
|
-
message: `No tennis match with id '${matchId}' — the odds feed has no event for it because the id itself does not exist`,
|
|
255
|
-
game,
|
|
256
|
-
source: 'tennis_odds',
|
|
257
|
-
requestId,
|
|
258
|
-
tookMs: Date.now() - started,
|
|
259
|
-
upstreamCalls,
|
|
260
|
-
rateLimit,
|
|
261
|
-
httpStatus: 200,
|
|
262
|
-
recover: [
|
|
263
|
-
'Check the id with live_matches or tennis_schedule / match_summary',
|
|
264
|
-
'Ids come from live_matches (s365_*) or the archive (s365_{year}_{gid})',
|
|
265
|
-
`For the pre-match odds list instead, call tennis_odds { game: "tennis", scope: "upcoming" }`,
|
|
266
|
-
],
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return successEnvelope({
|
|
271
|
-
source: 'tennis_odds',
|
|
272
|
-
game,
|
|
273
|
-
requestId,
|
|
274
|
-
tookMs: Date.now() - started,
|
|
275
|
-
upstreamCalls,
|
|
276
|
-
rateLimit,
|
|
277
|
-
data: {
|
|
278
|
-
title: `Tennis ${scopeRaw} odds — ${matchId}`,
|
|
279
|
-
scope: scopeRaw,
|
|
280
|
-
...summary,
|
|
281
|
-
matchId: summary.matchId ?? matchId,
|
|
282
|
-
matchExists,
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
export const oddsTools = [tennisOdds];
|