cito-mcp 0.4.6 → 0.4.8
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 +6 -7
- package/dist/instructions.js +1 -0
- package/dist/tools/combat.js +491 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/match.js +1 -1
- package/dist/tools/meta.js +120 -55
- package/package.json +51 -51
- package/dist/tools/odds.js +0 -287
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
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
|
-
Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis
|
|
7
|
+
Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**, plus **RAF wrestling** and **BKFC bare knuckle** through the `combat_*` tools. Long-tail REST stays available via `call_api`.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -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/instructions.js
CHANGED
|
@@ -17,6 +17,7 @@ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | t
|
|
|
17
17
|
- Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
|
|
18
18
|
- On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
|
|
19
19
|
- Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
|
|
20
|
+
- RAF (Real American Freestyle wrestling) and BKFC (Bare Knuckle Fighting Championship) are NOT on the game enum: use the combat_* tools with league raf|bkfc (combat_events, combat_card, combat_bout, combat_athlete, combat_h2h, combat_rankings), or call_api /raf/... and /bkfc/.... RAF scores per PERIOD (wrestling points, takedowns); BKFC per ROUND (punches landed/thrown, knockdowns). Never mix either into UFC answers.
|
|
20
21
|
|
|
21
22
|
## Preferred tool order
|
|
22
23
|
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { asRecord, clampInt, extractRows, fetchJson, gameNotIncludedHint } from '../client.js';
|
|
2
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
3
|
+
import { boolSchema, limitSchema, stringSchema } from './types.js';
|
|
4
|
+
export const COMBAT_LEAGUES = ['raf', 'bkfc'];
|
|
5
|
+
const LEAGUE_NAMES = {
|
|
6
|
+
raf: 'Real American Freestyle (pro freestyle wrestling)',
|
|
7
|
+
bkfc: 'Bare Knuckle Fighting Championship',
|
|
8
|
+
};
|
|
9
|
+
const PEOPLE = { raf: 'athletes', bkfc: 'fighters' };
|
|
10
|
+
const HISTORY = { raf: 'bouts', bkfc: 'fights' };
|
|
11
|
+
const leagueSchema = {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: [...COMBAT_LEAGUES],
|
|
14
|
+
description: 'League: "raf" (Real American Freestyle wrestling) or "bkfc" (Bare Knuckle Fighting Championship). Example: "bkfc".',
|
|
15
|
+
};
|
|
16
|
+
function parseLeague(value) {
|
|
17
|
+
const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
18
|
+
return COMBAT_LEAGUES.includes(v) ? v : null;
|
|
19
|
+
}
|
|
20
|
+
function leagueError(d, value) {
|
|
21
|
+
return errorEnvelope({
|
|
22
|
+
code: 'VALIDATION',
|
|
23
|
+
message: `league must be raf or bkfc (got ${JSON.stringify(value ?? null)})`,
|
|
24
|
+
game: null,
|
|
25
|
+
source: d.source,
|
|
26
|
+
requestId: d.requestId,
|
|
27
|
+
tookMs: Date.now() - d.started,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function httpError(d, league, res, what) {
|
|
31
|
+
const body = asRecord(res.data);
|
|
32
|
+
const err = asRecord(body?.error);
|
|
33
|
+
return errorEnvelope({
|
|
34
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
35
|
+
message: (typeof err?.message === 'string' && err.message) || `${what} failed (HTTP ${res.status})`,
|
|
36
|
+
game: league,
|
|
37
|
+
source: d.source,
|
|
38
|
+
httpStatus: res.status,
|
|
39
|
+
requestId: d.requestId,
|
|
40
|
+
tookMs: Date.now() - d.started,
|
|
41
|
+
rateLimit: res.headers,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function payload(res) {
|
|
45
|
+
const body = asRecord(res.data);
|
|
46
|
+
return body && 'data' in body ? body.data : res.data;
|
|
47
|
+
}
|
|
48
|
+
function sectionFailure(section, res) {
|
|
49
|
+
const body = asRecord(res.data);
|
|
50
|
+
const err = asRecord(body?.error);
|
|
51
|
+
const code = mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) });
|
|
52
|
+
return partialFromRejection(section, {
|
|
53
|
+
code,
|
|
54
|
+
httpStatus: res.status,
|
|
55
|
+
message: res.status === 403
|
|
56
|
+
? `${section} needs the Starter plan or above for this league (${err?.message || 'plan restriction'})`
|
|
57
|
+
: err?.message || `${section} unavailable (HTTP ${res.status})`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function resolvePerson(ctx, league, input) {
|
|
61
|
+
const raw = input.trim();
|
|
62
|
+
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(raw)) {
|
|
63
|
+
const probe = await fetchJson(ctx, `/${league}/${PEOPLE[league]}/${encodeURIComponent(raw)}`);
|
|
64
|
+
if (probe.ok)
|
|
65
|
+
return { slug: raw };
|
|
66
|
+
if (probe.status !== 404)
|
|
67
|
+
return { error: probe };
|
|
68
|
+
}
|
|
69
|
+
const res = await fetchJson(ctx, `/${league}/search`, { query: { q: raw.replace(/-/g, ' '), limit: 10 } });
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
return { error: res };
|
|
72
|
+
const hits = extractRows(res.data)
|
|
73
|
+
.map((r) => asRecord(r))
|
|
74
|
+
.filter((r) => !!r && (r.type === 'athlete' || r.type === 'fighter'));
|
|
75
|
+
if (!hits.length)
|
|
76
|
+
return { notFound: true };
|
|
77
|
+
const exact = hits.filter((h) => String(h.name ?? '').toLowerCase() === raw.toLowerCase());
|
|
78
|
+
if (exact.length === 1)
|
|
79
|
+
return { slug: String(exact[0].slug) };
|
|
80
|
+
if (hits.length === 1)
|
|
81
|
+
return { slug: String(hits[0].slug) };
|
|
82
|
+
return { candidates: hits.slice(0, 8) };
|
|
83
|
+
}
|
|
84
|
+
async function resolveOrFail(d, ctx, league, input, argName) {
|
|
85
|
+
const r = await resolvePerson(ctx, league, input);
|
|
86
|
+
if ('slug' in r)
|
|
87
|
+
return r;
|
|
88
|
+
if ('error' in r)
|
|
89
|
+
return { fail: httpError(d, league, r.error, `${argName} lookup`) };
|
|
90
|
+
if ('candidates' in r) {
|
|
91
|
+
return {
|
|
92
|
+
fail: errorEnvelope({
|
|
93
|
+
code: 'AMBIGUOUS_ENTITY',
|
|
94
|
+
message: `"${input}" matches several ${PEOPLE[league]}; pass one slug from details.candidates`,
|
|
95
|
+
game: league,
|
|
96
|
+
source: d.source,
|
|
97
|
+
requestId: d.requestId,
|
|
98
|
+
tookMs: Date.now() - d.started,
|
|
99
|
+
details: { candidates: r.candidates },
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
fail: errorEnvelope({
|
|
105
|
+
code: 'NOT_FOUND',
|
|
106
|
+
message: `No ${league.toUpperCase()} ${PEOPLE[league].slice(0, -1)} matches "${input}"`,
|
|
107
|
+
game: league,
|
|
108
|
+
source: d.source,
|
|
109
|
+
requestId: d.requestId,
|
|
110
|
+
tookMs: Date.now() - d.started,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function begin(source) {
|
|
115
|
+
return { started: Date.now(), requestId: newRequestId(), source };
|
|
116
|
+
}
|
|
117
|
+
export const combatEvents = {
|
|
118
|
+
name: 'combat_events',
|
|
119
|
+
description: `RAF wrestling or BKFC bare-knuckle events: upcoming cards or recent results.
|
|
120
|
+
|
|
121
|
+
When to use:
|
|
122
|
+
- "When is the next RAF / BKFC card?", "recent BKFC results", schedule pages
|
|
123
|
+
- Getting an event slug for combat_card
|
|
124
|
+
|
|
125
|
+
Each event carries slug, name, date, local start time + timezone, venue/location, status and bout counts.
|
|
126
|
+
|
|
127
|
+
Prefer over: call_api /raf/events or /bkfc/events.
|
|
128
|
+
Do not use when: you already have the event slug and want its bouts (combat_card).
|
|
129
|
+
|
|
130
|
+
Parallel-safe: yes. Upstream cost: 1.
|
|
131
|
+
Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
required: ['league'],
|
|
136
|
+
properties: {
|
|
137
|
+
league: leagueSchema,
|
|
138
|
+
when: {
|
|
139
|
+
type: 'string',
|
|
140
|
+
enum: ['upcoming', 'recent'],
|
|
141
|
+
default: 'upcoming',
|
|
142
|
+
description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Example: "recent".',
|
|
143
|
+
},
|
|
144
|
+
limit: limitSchema({ default: 10, max: 50, description: 'Max events (default 10, max 50).' }),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
handler: async (args, ctx) => {
|
|
148
|
+
const d = begin('combat_events');
|
|
149
|
+
const league = parseLeague(args.league);
|
|
150
|
+
if (!league)
|
|
151
|
+
return leagueError(d, args.league);
|
|
152
|
+
const when = args.when === 'recent' ? 'recent' : 'upcoming';
|
|
153
|
+
const limit = clampInt(args.limit, 10, 1, 50);
|
|
154
|
+
const res = await fetchJson(ctx, `/${league}/events/${when}`, { query: { limit } });
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
return httpError(d, league, res, `${league} ${when} events`);
|
|
157
|
+
const events = extractRows(res.data).slice(0, limit);
|
|
158
|
+
return successEnvelope({
|
|
159
|
+
data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events },
|
|
160
|
+
game: league,
|
|
161
|
+
source: d.source,
|
|
162
|
+
requestId: d.requestId,
|
|
163
|
+
tookMs: Date.now() - d.started,
|
|
164
|
+
upstreamCalls: 1,
|
|
165
|
+
rateLimit: res.headers,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
export const combatCard = {
|
|
170
|
+
name: 'combat_card',
|
|
171
|
+
description: `COMPOSITE fight card for one RAF or BKFC event: event identity + bouts in running order with results.
|
|
172
|
+
|
|
173
|
+
When to use:
|
|
174
|
+
- "RAF 14 card", "BKFC 95 results", "who won the main event at the last BKFC?"
|
|
175
|
+
- Event pages and results pages
|
|
176
|
+
|
|
177
|
+
event accepts a slug (e.g. "raf13", "bkfc-95-newark") or "next" / "last". Bouts come main event first; each carries
|
|
178
|
+
weight class, title stakes, both sides (athletes for RAF, red/blue fighters for BKFC with punch totals), and once
|
|
179
|
+
finished the winner and result (RAF: score, takedowns, method; BKFC: method, round, time).
|
|
180
|
+
|
|
181
|
+
Prefer over: combat_events + call_api per bout.
|
|
182
|
+
Do not use when: you need one bout's period/round breakdown (combat_bout).
|
|
183
|
+
|
|
184
|
+
Parallel-safe: yes. Upstream cost: 2–3.
|
|
185
|
+
Example: { "league": "raf", "event": "last" }`,
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
required: ['league'],
|
|
190
|
+
properties: {
|
|
191
|
+
league: leagueSchema,
|
|
192
|
+
event: stringSchema('Event slug, or "next" / "last" (default "next"). Example: "raf13".', 'next'),
|
|
193
|
+
limit: limitSchema({ default: 30, max: 40, description: 'Max bouts on the card (default 30, max 40).' }),
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
handler: async (args, ctx) => {
|
|
197
|
+
const d = begin('combat_card');
|
|
198
|
+
const league = parseLeague(args.league);
|
|
199
|
+
if (!league)
|
|
200
|
+
return leagueError(d, args.league);
|
|
201
|
+
const limit = clampInt(args.limit, 30, 1, 40);
|
|
202
|
+
let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : 'next';
|
|
203
|
+
let calls = 0;
|
|
204
|
+
if (slug === 'next' || slug === 'last') {
|
|
205
|
+
const which = slug === 'next' ? 'upcoming' : 'recent';
|
|
206
|
+
const list = await fetchJson(ctx, `/${league}/events/${which}`, { query: { limit: 1 } });
|
|
207
|
+
calls += 1;
|
|
208
|
+
if (!list.ok)
|
|
209
|
+
return httpError(d, league, list, `${league} ${which} events`);
|
|
210
|
+
const first = asRecord(extractRows(list.data)[0]);
|
|
211
|
+
if (!first?.slug) {
|
|
212
|
+
return errorEnvelope({
|
|
213
|
+
code: 'NOT_FOUND',
|
|
214
|
+
message: `No ${which} ${league.toUpperCase()} event`,
|
|
215
|
+
game: league,
|
|
216
|
+
source: d.source,
|
|
217
|
+
requestId: d.requestId,
|
|
218
|
+
tookMs: Date.now() - d.started,
|
|
219
|
+
upstreamCalls: calls,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
slug = String(first.slug);
|
|
223
|
+
}
|
|
224
|
+
const [ev, bouts] = await Promise.all([
|
|
225
|
+
fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`),
|
|
226
|
+
fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}/bouts`),
|
|
227
|
+
]);
|
|
228
|
+
calls += 2;
|
|
229
|
+
if (!ev.ok)
|
|
230
|
+
return httpError(d, league, ev, `${league} event ${slug}`);
|
|
231
|
+
const partial = [];
|
|
232
|
+
let rows = [];
|
|
233
|
+
if (bouts.ok) {
|
|
234
|
+
const p = payload(bouts);
|
|
235
|
+
rows = Array.isArray(p) ? p : extractRows(asRecord(p)?.bouts ?? p);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
partial.push(sectionFailure('bouts', bouts));
|
|
239
|
+
}
|
|
240
|
+
const eventData = asRecord(payload(ev)) ?? {};
|
|
241
|
+
const { bouts: _ignored, ...event } = eventData;
|
|
242
|
+
return successEnvelope({
|
|
243
|
+
data: { league, event, boutCount: rows.length, bouts: rows.slice(0, limit) },
|
|
244
|
+
game: league,
|
|
245
|
+
source: d.source,
|
|
246
|
+
requestId: d.requestId,
|
|
247
|
+
tookMs: Date.now() - d.started,
|
|
248
|
+
upstreamCalls: calls,
|
|
249
|
+
rateLimit: bouts.headers ?? ev.headers,
|
|
250
|
+
partial,
|
|
251
|
+
truncated: rows.length > limit
|
|
252
|
+
? { applied: true, reason: 'section_item_cap', dropped: { itemsFrom: limit, itemsTo: rows.length }, recover: ['Raise limit (max 40)'] }
|
|
253
|
+
: null,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
export const combatBout = {
|
|
258
|
+
name: 'combat_bout',
|
|
259
|
+
description: `One RAF or BKFC bout in full, with its scoring breakdown.
|
|
260
|
+
|
|
261
|
+
RAF: winner, method (decision / tech fall / pin / injury), final score, takedowns, and per-PERIOD scores.
|
|
262
|
+
BKFC: winner, method, round, time, and per-ROUND punch stats for red and blue (landed/thrown, head/body,
|
|
263
|
+
distance/clinch, knockdowns). BKFC round stats need the Starter plan; on Free they come back as a partial note.
|
|
264
|
+
|
|
265
|
+
Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington").
|
|
266
|
+
|
|
267
|
+
Parallel-safe: yes. Upstream cost: 1–2.
|
|
268
|
+
Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cisneros--ryan-petersen" }`,
|
|
269
|
+
inputSchema: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
required: ['league', 'boutId'],
|
|
273
|
+
properties: {
|
|
274
|
+
league: leagueSchema,
|
|
275
|
+
boutId: stringSchema('Bout id from combat_card / combat_athlete. Example: "raf13--belal-muhammad--colby-covington".'),
|
|
276
|
+
includeBreakdown: boolSchema('Include per-period (RAF) / per-round (BKFC) breakdown (default true).', true),
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
handler: async (args, ctx) => {
|
|
280
|
+
const d = begin('combat_bout');
|
|
281
|
+
const league = parseLeague(args.league);
|
|
282
|
+
if (!league)
|
|
283
|
+
return leagueError(d, args.league);
|
|
284
|
+
const boutId = typeof args.boutId === 'string' ? args.boutId.trim() : '';
|
|
285
|
+
if (!boutId) {
|
|
286
|
+
return errorEnvelope({ code: 'VALIDATION', message: 'boutId is required', game: league, source: d.source,
|
|
287
|
+
requestId: d.requestId, tookMs: Date.now() - d.started });
|
|
288
|
+
}
|
|
289
|
+
const id = encodeURIComponent(boutId);
|
|
290
|
+
const breakdown = args.includeBreakdown !== false;
|
|
291
|
+
const sub = league === 'raf' ? 'periods' : 'rounds';
|
|
292
|
+
const [bout, parts] = await Promise.all([
|
|
293
|
+
fetchJson(ctx, `/${league}/bouts/${id}`),
|
|
294
|
+
breakdown ? fetchJson(ctx, `/${league}/bouts/${id}/${sub}`) : Promise.resolve(null),
|
|
295
|
+
]);
|
|
296
|
+
if (!bout.ok)
|
|
297
|
+
return httpError(d, league, bout, `${league} bout ${boutId}`);
|
|
298
|
+
const partial = [];
|
|
299
|
+
const data = { league, bout: payload(bout) };
|
|
300
|
+
if (parts) {
|
|
301
|
+
if (parts.ok)
|
|
302
|
+
data[sub] = asRecord(payload(parts))?.[sub] ?? payload(parts);
|
|
303
|
+
else
|
|
304
|
+
partial.push(sectionFailure(sub, parts));
|
|
305
|
+
}
|
|
306
|
+
const b = asRecord(data.bout);
|
|
307
|
+
if (league === 'bkfc' && b && 'rounds' in b && data.rounds)
|
|
308
|
+
delete b.rounds;
|
|
309
|
+
return successEnvelope({
|
|
310
|
+
data,
|
|
311
|
+
game: league,
|
|
312
|
+
source: d.source,
|
|
313
|
+
requestId: d.requestId,
|
|
314
|
+
tookMs: Date.now() - d.started,
|
|
315
|
+
upstreamCalls: parts ? 2 : 1,
|
|
316
|
+
rateLimit: bout.headers,
|
|
317
|
+
partial,
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
export const combatAthlete = {
|
|
322
|
+
name: 'combat_athlete',
|
|
323
|
+
description: `COMPOSITE RAF athlete / BKFC fighter page: profile + recent bouts + career stats.
|
|
324
|
+
|
|
325
|
+
When to use:
|
|
326
|
+
- "Kyle Snyder's RAF record", "Arlovski BKFC stats", athlete/fighter profile pages
|
|
327
|
+
|
|
328
|
+
athlete accepts a slug ("kyle-snyder") or a name ("Andrei Arlovski"); names are resolved through the league's
|
|
329
|
+
search, and several matches return AMBIGUOUS_ENTITY with candidates. Profile carries record, division, nickname,
|
|
330
|
+
country, official headshot (+ its credit — show it with the image). Career stats need the Starter plan; on Free
|
|
331
|
+
they come back as a partial note while profile and history still return.
|
|
332
|
+
|
|
333
|
+
Prefer over: call_api profile + history + stats separately.
|
|
334
|
+
|
|
335
|
+
Parallel-safe: yes. Upstream cost: 2–4.
|
|
336
|
+
Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
additionalProperties: false,
|
|
340
|
+
required: ['league', 'athlete'],
|
|
341
|
+
properties: {
|
|
342
|
+
league: leagueSchema,
|
|
343
|
+
athlete: stringSchema('Athlete/fighter slug or name. Example: "kyle-snyder".'),
|
|
344
|
+
includeHistory: boolSchema('Include recent bouts (default true).', true),
|
|
345
|
+
historyLimit: limitSchema({ default: 10, max: 50, description: 'Max bouts of history (default 10, max 50).' }),
|
|
346
|
+
includeStats: boolSchema('Include career stats (default true; Starter plan).', true),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
handler: async (args, ctx) => {
|
|
350
|
+
const d = begin('combat_athlete');
|
|
351
|
+
const league = parseLeague(args.league);
|
|
352
|
+
if (!league)
|
|
353
|
+
return leagueError(d, args.league);
|
|
354
|
+
const input = typeof args.athlete === 'string' ? args.athlete : typeof args.fighter === 'string' ? args.fighter : '';
|
|
355
|
+
if (!input.trim()) {
|
|
356
|
+
return errorEnvelope({ code: 'VALIDATION', message: 'athlete is required (slug or name)', game: league,
|
|
357
|
+
source: d.source, requestId: d.requestId, tookMs: Date.now() - d.started });
|
|
358
|
+
}
|
|
359
|
+
const r = await resolveOrFail(d, ctx, league, input, 'athlete');
|
|
360
|
+
if ('fail' in r)
|
|
361
|
+
return r.fail;
|
|
362
|
+
const base = `/${league}/${PEOPLE[league]}/${encodeURIComponent(r.slug)}`;
|
|
363
|
+
const historyLimit = clampInt(args.historyLimit, 10, 1, 50);
|
|
364
|
+
const [profile, history, stats] = await Promise.all([
|
|
365
|
+
fetchJson(ctx, base),
|
|
366
|
+
args.includeHistory === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/${HISTORY[league]}`, { query: { limit: historyLimit } }),
|
|
367
|
+
args.includeStats === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/stats`),
|
|
368
|
+
]);
|
|
369
|
+
if (!profile.ok)
|
|
370
|
+
return httpError(d, league, profile, `${league} ${PEOPLE[league]} ${r.slug}`);
|
|
371
|
+
const partial = [];
|
|
372
|
+
const data = { league, slug: r.slug, profile: payload(profile) };
|
|
373
|
+
if (history) {
|
|
374
|
+
if (history.ok)
|
|
375
|
+
data.history = extractRows(history.data).slice(0, historyLimit);
|
|
376
|
+
else
|
|
377
|
+
partial.push(sectionFailure('history', history));
|
|
378
|
+
}
|
|
379
|
+
if (stats) {
|
|
380
|
+
if (stats.ok)
|
|
381
|
+
data.stats = payload(stats);
|
|
382
|
+
else
|
|
383
|
+
partial.push(sectionFailure('stats', stats));
|
|
384
|
+
}
|
|
385
|
+
return successEnvelope({
|
|
386
|
+
data,
|
|
387
|
+
game: league,
|
|
388
|
+
source: d.source,
|
|
389
|
+
requestId: d.requestId,
|
|
390
|
+
tookMs: Date.now() - d.started,
|
|
391
|
+
upstreamCalls: 1 + (history ? 1 : 0) + (stats ? 1 : 0),
|
|
392
|
+
rateLimit: profile.headers,
|
|
393
|
+
partial,
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
export const combatH2h = {
|
|
398
|
+
name: 'combat_h2h',
|
|
399
|
+
description: `Head-to-head between two RAF athletes or two BKFC fighters: record and every meeting.
|
|
400
|
+
|
|
401
|
+
Accepts slugs or names for both sides (names resolve through the league's search).
|
|
402
|
+
|
|
403
|
+
Parallel-safe: yes. Upstream cost: 1–3.
|
|
404
|
+
Example: { "league": "bkfc", "athleteA": "ryan-petersen", "athleteB": "gregoris-cisneros" }`,
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
additionalProperties: false,
|
|
408
|
+
required: ['league', 'athleteA', 'athleteB'],
|
|
409
|
+
properties: {
|
|
410
|
+
league: leagueSchema,
|
|
411
|
+
athleteA: stringSchema('First athlete/fighter slug or name. Example: "kyle-snyder".'),
|
|
412
|
+
athleteB: stringSchema('Second athlete/fighter slug or name. Example: "wyatt-hendrickson".'),
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
handler: async (args, ctx) => {
|
|
416
|
+
const d = begin('combat_h2h');
|
|
417
|
+
const league = parseLeague(args.league);
|
|
418
|
+
if (!league)
|
|
419
|
+
return leagueError(d, args.league);
|
|
420
|
+
const a = typeof args.athleteA === 'string' ? args.athleteA : '';
|
|
421
|
+
const b = typeof args.athleteB === 'string' ? args.athleteB : '';
|
|
422
|
+
if (!a.trim() || !b.trim()) {
|
|
423
|
+
return errorEnvelope({ code: 'VALIDATION', message: 'athleteA and athleteB are required', game: league,
|
|
424
|
+
source: d.source, requestId: d.requestId, tookMs: Date.now() - d.started });
|
|
425
|
+
}
|
|
426
|
+
const [ra, rb] = await Promise.all([
|
|
427
|
+
resolveOrFail(d, ctx, league, a, 'athleteA'),
|
|
428
|
+
resolveOrFail(d, ctx, league, b, 'athleteB'),
|
|
429
|
+
]);
|
|
430
|
+
if ('fail' in ra)
|
|
431
|
+
return ra.fail;
|
|
432
|
+
if ('fail' in rb)
|
|
433
|
+
return rb.fail;
|
|
434
|
+
const query = league === 'raf' ? { athlete1: ra.slug, athlete2: rb.slug } : { fighter1: ra.slug, fighter2: rb.slug };
|
|
435
|
+
const res = await fetchJson(ctx, `/${league}/h2h`, { query });
|
|
436
|
+
if (!res.ok)
|
|
437
|
+
return httpError(d, league, res, `${league} head-to-head`);
|
|
438
|
+
return successEnvelope({
|
|
439
|
+
data: { league, athleteA: ra.slug, athleteB: rb.slug, h2h: payload(res) },
|
|
440
|
+
game: league,
|
|
441
|
+
source: d.source,
|
|
442
|
+
requestId: d.requestId,
|
|
443
|
+
tookMs: Date.now() - d.started,
|
|
444
|
+
upstreamCalls: 1,
|
|
445
|
+
rateLimit: res.headers,
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
export const combatRankings = {
|
|
450
|
+
name: 'combat_rankings',
|
|
451
|
+
description: `Champions and rankings: RAF title holders per division, BKFC official rankings + champions.
|
|
452
|
+
|
|
453
|
+
BKFC: omit division for every division's ranked list (champion, interim champion, ranked fighters), or pass one
|
|
454
|
+
(e.g. "heavyweight", "womens-flyweight"). RAF has no ranked lists; it returns every title and its holder, or one
|
|
455
|
+
division's detail when division is given (e.g. "men-heavyweight").
|
|
456
|
+
|
|
457
|
+
Parallel-safe: yes. Upstream cost: 1.
|
|
458
|
+
Example: { "league": "bkfc", "division": "heavyweight" }`,
|
|
459
|
+
inputSchema: {
|
|
460
|
+
type: 'object',
|
|
461
|
+
additionalProperties: false,
|
|
462
|
+
required: ['league'],
|
|
463
|
+
properties: {
|
|
464
|
+
league: leagueSchema,
|
|
465
|
+
division: stringSchema('Optional division key. Example: "heavyweight".'),
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
handler: async (args, ctx) => {
|
|
469
|
+
const d = begin('combat_rankings');
|
|
470
|
+
const league = parseLeague(args.league);
|
|
471
|
+
if (!league)
|
|
472
|
+
return leagueError(d, args.league);
|
|
473
|
+
const division = typeof args.division === 'string' ? args.division.trim().toLowerCase() : '';
|
|
474
|
+
const path = league === 'bkfc'
|
|
475
|
+
? division ? `/bkfc/rankings/${encodeURIComponent(division)}` : '/bkfc/rankings'
|
|
476
|
+
: division ? `/raf/divisions/${encodeURIComponent(division)}` : '/raf/champions';
|
|
477
|
+
const res = await fetchJson(ctx, path);
|
|
478
|
+
if (!res.ok)
|
|
479
|
+
return httpError(d, league, res, `${league} rankings`);
|
|
480
|
+
return successEnvelope({
|
|
481
|
+
data: { league, division: division || null, rankings: payload(res) },
|
|
482
|
+
game: league,
|
|
483
|
+
source: d.source,
|
|
484
|
+
requestId: d.requestId,
|
|
485
|
+
tookMs: Date.now() - d.started,
|
|
486
|
+
upstreamCalls: 1,
|
|
487
|
+
rateLimit: res.headers,
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
export const combatTools = [combatEvents, combatCard, combatBout, combatAthlete, combatH2h, combatRankings];
|
package/dist/tools/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { insightTools } from './insight.js';
|
|
|
11
11
|
import { cs2Tools } from './cs2.js';
|
|
12
12
|
import { tournamentTools } from './tournaments.js';
|
|
13
13
|
import { scheduleTools } from './schedule.js';
|
|
14
|
+
import { combatTools } from './combat.js';
|
|
14
15
|
export const allTools = [
|
|
15
16
|
...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
|
|
16
17
|
...resolveTools,
|
|
@@ -25,6 +26,7 @@ export const allTools = [
|
|
|
25
26
|
...scheduleTools,
|
|
26
27
|
...insightTools,
|
|
27
28
|
...cs2Tools,
|
|
29
|
+
...combatTools,
|
|
28
30
|
...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
|
|
29
31
|
];
|
|
30
32
|
export function getTool(name) {
|
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
|
@@ -2,6 +2,68 @@ import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.
|
|
|
2
2
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
3
3
|
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
4
4
|
import { PACKAGE_VERSION as CATALOG_VERSION } from '../version.js';
|
|
5
|
+
const COMBAT_CATALOG = [
|
|
6
|
+
{
|
|
7
|
+
name: 'combat_events',
|
|
8
|
+
doNotUse: 'You already have the event slug → combat_card',
|
|
9
|
+
outcome: 'RAF wrestling / BKFC bare-knuckle events: upcoming cards or recent results, with date, local time and venue',
|
|
10
|
+
parallelSafe: true,
|
|
11
|
+
games: ['raf', 'bkfc'],
|
|
12
|
+
jobs: ['schedule'],
|
|
13
|
+
exampleArgs: { league: 'bkfc', when: 'upcoming', limit: 5 },
|
|
14
|
+
preferOver: ['call_api /raf/events or /bkfc/events'],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'combat_card',
|
|
18
|
+
doNotUse: "One bout's period/round breakdown → combat_bout",
|
|
19
|
+
outcome: 'One RAF/BKFC event card in running order with results; event = slug, "next" or "last"',
|
|
20
|
+
parallelSafe: true,
|
|
21
|
+
games: ['raf', 'bkfc'],
|
|
22
|
+
jobs: ['event_card', 'schedule', 'media'],
|
|
23
|
+
exampleArgs: { league: 'raf', event: 'last' },
|
|
24
|
+
preferOver: ['combat_events + call_api per bout'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'combat_bout',
|
|
28
|
+
doNotUse: 'Whole card → combat_card',
|
|
29
|
+
preferOver: [],
|
|
30
|
+
outcome: 'One bout in full: RAF per-period scores / BKFC per-round punch stats (Starter)',
|
|
31
|
+
parallelSafe: true,
|
|
32
|
+
games: ['raf', 'bkfc'],
|
|
33
|
+
jobs: ['match_page'],
|
|
34
|
+
exampleArgs: { league: 'raf', boutId: 'raf13--belal-muhammad--colby-covington' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'combat_athlete',
|
|
38
|
+
doNotUse: "Two athletes' meetings → combat_h2h",
|
|
39
|
+
preferOver: [],
|
|
40
|
+
outcome: 'RAF athlete / BKFC fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name',
|
|
41
|
+
parallelSafe: true,
|
|
42
|
+
games: ['raf', 'bkfc'],
|
|
43
|
+
jobs: ['player_form', 'media'],
|
|
44
|
+
exampleArgs: { league: 'raf', athlete: 'Kyle Snyder', historyLimit: 5 },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'combat_h2h',
|
|
48
|
+
doNotUse: "One athlete's whole history → combat_athlete",
|
|
49
|
+
preferOver: [],
|
|
50
|
+
outcome: 'Head-to-head between two RAF athletes or two BKFC fighters',
|
|
51
|
+
parallelSafe: true,
|
|
52
|
+
games: ['raf', 'bkfc'],
|
|
53
|
+
jobs: ['h2h', 'preview'],
|
|
54
|
+
exampleArgs: { league: 'bkfc', athleteA: 'ryan-petersen', athleteB: 'gregoris-cisneros' },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'combat_rankings',
|
|
58
|
+
doNotUse: 'UFC rankings → standings / rankings tools (RAF/BKFC never appear in UFC answers)',
|
|
59
|
+
preferOver: [],
|
|
60
|
+
outcome: 'RAF champions per division / BKFC official rankings and champions',
|
|
61
|
+
parallelSafe: true,
|
|
62
|
+
games: ['raf', 'bkfc'],
|
|
63
|
+
jobs: ['standings'],
|
|
64
|
+
exampleArgs: { league: 'bkfc', division: 'heavyweight' },
|
|
65
|
+
},
|
|
66
|
+
];
|
|
5
67
|
const TOOL_CATALOG = [
|
|
6
68
|
{
|
|
7
69
|
name: 'list_capabilities',
|
|
@@ -419,6 +481,7 @@ const TOOL_CATALOG = [
|
|
|
419
481
|
preferOver: [],
|
|
420
482
|
doNotUse: 'Any job covered by a curated tool',
|
|
421
483
|
},
|
|
484
|
+
...COMBAT_CATALOG,
|
|
422
485
|
];
|
|
423
486
|
const JOBS = [
|
|
424
487
|
{ id: 'live_board', description: 'Ops/dashboard live strip', recommendedTools: ['live_matches', 'match_summary'] },
|
|
@@ -488,18 +551,18 @@ const GAMES_META = [
|
|
|
488
551
|
];
|
|
489
552
|
export const listCapabilities = {
|
|
490
553
|
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.
|
|
554
|
+
description: `Curated catalog of cito-mcp tools, games, jobs, and builder recipes.
|
|
555
|
+
|
|
556
|
+
When to use:
|
|
557
|
+
- Session start or "what can you do?"
|
|
558
|
+
- Mapping app screens to tools
|
|
559
|
+
- Filtering by game or job (live_board, match_page, team_page, player_form, standings, h2h, schedule, preview, event_card, app_scaffold)
|
|
560
|
+
|
|
561
|
+
Prefer over: guessing from memory; exploring raw OpenAPI via call_api.
|
|
562
|
+
|
|
563
|
+
Do not use when: you already know the tool and have IDs — call that tool directly.
|
|
564
|
+
|
|
565
|
+
Parallel-safe: yes. Upstream cost: 0.
|
|
503
566
|
Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
|
|
504
567
|
inputSchema: {
|
|
505
568
|
type: 'object',
|
|
@@ -582,18 +645,18 @@ Example: { "game": "cs2", "job": "live_board", "includeExamples": true }`,
|
|
|
582
645
|
};
|
|
583
646
|
export const apiHealth = {
|
|
584
647
|
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.
|
|
648
|
+
description: `API reachability, API key validity, plan tier, rate-limit headers, and best-effort included games.
|
|
649
|
+
|
|
650
|
+
When to use:
|
|
651
|
+
- Once per session before heavy work
|
|
652
|
+
- After 401/403/UNSUPPORTED_GAME/RATE_LIMIT
|
|
653
|
+
- App scaffolding entitlement checks
|
|
654
|
+
|
|
655
|
+
Prefer over: probing random game endpoints to test the key.
|
|
656
|
+
|
|
657
|
+
Do not use when: you only need live scores — use live_matches.
|
|
658
|
+
|
|
659
|
+
Parallel-safe: yes. Upstream cost: 1–6.
|
|
597
660
|
Example: { "includeGameProbes": true }`,
|
|
598
661
|
inputSchema: {
|
|
599
662
|
type: 'object',
|
|
@@ -724,6 +787,8 @@ const ALLOWLIST_PREFIXES = [
|
|
|
724
787
|
'/ufc',
|
|
725
788
|
'/fortnite',
|
|
726
789
|
'/tennis',
|
|
790
|
+
'/raf',
|
|
791
|
+
'/bkfc',
|
|
727
792
|
'/openapi.json',
|
|
728
793
|
];
|
|
729
794
|
function pathAllowed(path) {
|
|
@@ -736,21 +801,21 @@ function pathAllowed(path) {
|
|
|
736
801
|
}
|
|
737
802
|
export const callApi = {
|
|
738
803
|
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.
|
|
804
|
+
description: `Power escape hatch: allowlisted Cito REST call with unshaped raw JSON in data.raw.
|
|
805
|
+
|
|
806
|
+
When to use:
|
|
807
|
+
- Long-tail paths not yet curated (Fortnite, CS2 leaderboards, niche stats)
|
|
808
|
+
- Debugging payloads while building an app
|
|
809
|
+
- User explicitly knows an OpenAPI path
|
|
810
|
+
|
|
811
|
+
Prefer curated tools for all standard jobs (live, schedule, profiles, standings, H2H, previews).
|
|
812
|
+
|
|
813
|
+
Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
|
|
814
|
+
|
|
815
|
+
Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc.
|
|
816
|
+
Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
|
|
817
|
+
|
|
818
|
+
Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
|
|
754
819
|
Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"page\\":1,\\"limit\\":20}" }`,
|
|
755
820
|
inputSchema: {
|
|
756
821
|
type: 'object',
|
|
@@ -872,20 +937,20 @@ const SPEC_OMITS = {
|
|
|
872
937
|
};
|
|
873
938
|
export const listRoutes = {
|
|
874
939
|
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.
|
|
940
|
+
description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
|
|
941
|
+
|
|
942
|
+
When to use:
|
|
943
|
+
- You need a long-tail path for call_api and do not want to guess
|
|
944
|
+
- Checking whether an endpoint exists before building around it
|
|
945
|
+
- Mapping what raw data backs a curated tool
|
|
946
|
+
|
|
947
|
+
Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
|
|
948
|
+
|
|
949
|
+
Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
|
|
950
|
+
|
|
951
|
+
Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
|
|
952
|
+
|
|
953
|
+
Parallel-safe: yes. Upstream cost: 1.
|
|
889
954
|
Example: { "game": "ufc", "q": "rankings" }`,
|
|
890
955
|
inputSchema: {
|
|
891
956
|
type: 'object',
|
|
@@ -909,10 +974,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
|
|
|
909
974
|
const started = Date.now();
|
|
910
975
|
const requestId = newRequestId();
|
|
911
976
|
const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
|
|
912
|
-
if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
|
|
977
|
+
if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc)$/)) {
|
|
913
978
|
return errorEnvelope({
|
|
914
979
|
code: 'UNSUPPORTED_GAME',
|
|
915
|
-
message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis`,
|
|
980
|
+
message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc`,
|
|
916
981
|
game: null,
|
|
917
982
|
source: 'list_routes',
|
|
918
983
|
requestId,
|
package/package.json
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cito-mcp",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports and sports API
|
|
5
|
-
"type": "module",
|
|
6
|
-
"homepage": "https://cito.gg",
|
|
7
|
-
"//REPOSITORY": "Intentionally NOT set. cito-api-scraper and cito.gg are PRIVATE repos and must stay that way, so a repository URL pointing at them would render as a broken 404 link on the public npm page and would not satisfy MCP Registry npm ownership validation. Set this only if a PUBLIC repo for this package is ever created (e.g. achillesscriptsvip/cito-mcp with this directory as root)
|
|
8
|
-
"keywords": [
|
|
9
|
-
"mcp",
|
|
10
|
-
"model-context-protocol",
|
|
11
|
-
"esports",
|
|
12
|
-
"cs2",
|
|
13
|
-
"counter-strike",
|
|
14
|
-
"counter-strike-2",
|
|
15
|
-
"lol",
|
|
16
|
-
"league-of-legends",
|
|
17
|
-
"dota2",
|
|
18
|
-
"cod",
|
|
19
|
-
"ufc",
|
|
20
|
-
"mma",
|
|
21
|
-
"tennis",
|
|
22
|
-
"live-scores",
|
|
23
|
-
"sports-data",
|
|
24
|
-
"esports-api"
|
|
25
|
-
],
|
|
26
|
-
"bin": {
|
|
27
|
-
"cito-mcp": "dist/index.js"
|
|
28
|
-
},
|
|
29
|
-
"files": [
|
|
30
|
-
"dist",
|
|
31
|
-
"README.md"
|
|
32
|
-
],
|
|
33
|
-
"engines": {
|
|
34
|
-
"node": ">=20"
|
|
35
|
-
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.json",
|
|
38
|
-
"test": "tsx --test src/**/*.test.ts src/*.test.ts",
|
|
39
|
-
"start": "node dist/index.js",
|
|
40
|
-
"prepublishOnly": "npm run build && npm test && npm run smoke",
|
|
41
|
-
"smoke": "node scripts/smoke.mjs"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@modelcontextprotocol/sdk": "1.29.0"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@types/node": "^22.10.0",
|
|
48
|
-
"tsx": "^4.23.1",
|
|
49
|
-
"typescript": "^5.9.2"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cito-mcp",
|
|
3
|
+
"version": "0.4.8",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports and sports API \u2014 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://cito.gg",
|
|
7
|
+
"//REPOSITORY": "Intentionally NOT set. cito-api-scraper and cito.gg are PRIVATE repos and must stay that way, so a repository URL pointing at them would render as a broken 404 link on the public npm page and would not satisfy MCP Registry npm ownership validation. Set this only if a PUBLIC repo for this package is ever created (e.g. achillesscriptsvip/cito-mcp with this directory as root) \u2014 then use `git+https://github.com/OWNER/REPO.git` and mirror it in the `mcpName`/server.json `name` pair. Do not point it at the private monorepo.",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"esports",
|
|
12
|
+
"cs2",
|
|
13
|
+
"counter-strike",
|
|
14
|
+
"counter-strike-2",
|
|
15
|
+
"lol",
|
|
16
|
+
"league-of-legends",
|
|
17
|
+
"dota2",
|
|
18
|
+
"cod",
|
|
19
|
+
"ufc",
|
|
20
|
+
"mma",
|
|
21
|
+
"tennis",
|
|
22
|
+
"live-scores",
|
|
23
|
+
"sports-data",
|
|
24
|
+
"esports-api"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"cito-mcp": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"test": "tsx --test src/**/*.test.ts src/*.test.ts",
|
|
39
|
+
"start": "node dist/index.js",
|
|
40
|
+
"prepublishOnly": "npm run build && npm test && npm run smoke",
|
|
41
|
+
"smoke": "node scripts/smoke.mjs"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"tsx": "^4.23.1",
|
|
49
|
+
"typescript": "^5.9.2"
|
|
50
|
+
}
|
|
51
|
+
}
|
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];
|