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 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.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.4.7` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
- Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
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).
@@ -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];
@@ -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) {
@@ -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
  }
@@ -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.6",
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
- "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) — 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
- }
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
+ }
@@ -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];