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 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.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
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).
@@ -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 not curated for ${game}. Currently available: UFC (/ufc/bouts/{id}/odds). Tennis odds were withdrawn.`,
812
+ message: `Odds are only available for UFC (/ufc/bouts/{id}/odds).`,
813
813
  }));
814
814
  }
815
815
  }
@@ -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.6",
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",
@@ -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];