cito-mcp 0.4.8 → 0.4.9

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.4.7` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.4.9` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
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`.
7
+ Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**, plus **RAF wrestling**, **BKFC bare knuckle** and the **Bellator MMA archive** (2014-2017) through the `combat_*` tools. Long-tail REST stays available via `call_api`.
8
8
 
9
9
  ---
10
10
 
@@ -240,7 +240,17 @@ upstream, so a non-null `headshotUrl` always has a `license` beside it.
240
240
  | `cs2` | deep | Prefixed ids; live path **`GET /cs2/live`**; trends / rankings |
241
241
  | `dota2` | thin | Numeric ids; radar; weak/no standings & roster endpoints |
242
242
  | `cod` | medium | Org slugs; CDL standings; UUID match ids |
243
- | `ufc` | medium | Fighter slugs; `boutId`; live + rankings |
243
+ | `ufc` | medium | Fighter slugs; `boutId`; live + rankings. Includes Dana White's Contender Series and Road to UFC (2022-2026, all 19 cards with official round stats; event slugs like `road-to-ufc-2-7`, titles "Road to UFC 2.7") |
244
+
245
+ ### Combat leagues (`combat_*`, `league` argument)
246
+
247
+ RAF, BKFC and Bellator are not on the `game` enum. `combat_events`, `combat_card`, `combat_bout`, `combat_athlete`, `combat_h2h` and `combat_rankings` take `league: "raf" | "bkfc" | "bellator"`, and `call_api` reaches `/raf`, `/bkfc` and `/bellator`. None of them is ever mixed into UFC answers.
248
+
249
+ | League | Scoring | Notes |
250
+ | --- | --- | --- |
251
+ | `raf` | per PERIOD (wrestling points, takedowns) | Athletes; champions per division |
252
+ | `bkfc` | per ROUND (punches landed/thrown, knockdowns) | Fighters; official rankings + champions |
253
+ | `bellator` | per ROUND, official stats in UFC's shape (`"13 of 18"`, `controlTime "1:42"`, round 0 = whole fight; knockdowns, submission attempts, reversals) | Closed archive: Bellator 131-172 (Nov 2014 - Feb 2017), ~20 cards / ~230 bouts. Bout ids like `bellator-5600`, event slugs like `bellator-142` (or `142`). A fighter we also hold a UFC record for keeps the **same slug** as `/ufc/fighters/{slug}` (`ufcFighterSlug`). No live data, rankings or upcoming cards: `combat_events` `when:"upcoming"` returns `[]` with a note (default is `recent`), `combat_card` `event:"last"` is the most recent card (the default) and `"next"` is a non-retryable `NOT_IMPLEMENTED`, `combat_rankings` is a non-retryable `NOT_IMPLEMENTED`. All of it is older than the Free (30-day) / Starter (90-day) history window, so its stats are Pro: on those plans round stats come back as a `HISTORY_WINDOW_EXCEEDED` partial note while results still return. |
244
254
 
245
255
  ### UFC projection notes (0.2.4)
246
256
 
@@ -259,7 +269,7 @@ Fortnite: **`call_api` only** until promoted into the primary enum.
259
269
 
260
270
  Path must start with `/` and match one of:
261
271
 
262
- `/health` · `/lol` · `/cs2` · `/dota2` · `/cod` · `/ufc` · `/fortnite`
272
+ `/health` · `/lol` · `/cs2` · `/dota2` · `/cod` · `/ufc` · `/fortnite` · `/tennis` · `/raf` · `/bkfc` · `/bellator` · `/openapi.json`
263
273
 
264
274
  Absolute URLs and `..` traversal → `PATH_NOT_ALLOWED`. Prefer curated tools for all standard jobs.
265
275
 
@@ -540,6 +550,15 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
540
550
 
541
551
  ## Changelog (summary)
542
552
 
553
+ ### 0.4.9
554
+
555
+ - **Bellator MMA archive** (`/api/v1/bellator`): `league: "bellator"` on every `combat_*` tool, and `/bellator` on the `call_api` / `list_routes` allowlist. Bellator 131-172 (Nov 2014 - Feb 2017) with official per-round stats in `/ufc`'s shape; fighters with a UFC record share the UFC slug (`combat_athlete` returns `ufcFighterSlug`). Archive gaps are answered honestly: upcoming events are an empty list with a note, `combat_card` defaults to the most recent card and refuses `"next"`, and `combat_rankings` returns a non-retryable `NOT_IMPLEMENTED`. `HISTORY_WINDOW_EXCEEDED` sections are reported as a plan gap (Pro), not as a key problem.
556
+ - Agent guidance (server instructions, `list_capabilities`): Bellator is never mixed into UFC answers; UFC already includes Dana White's Contender Series and **Road to UFC** (2022-2026, all 19 cards with official round stats, slugs like `road-to-ufc-2-7`).
557
+
558
+ ### 0.4.8
559
+
560
+ - RAF wrestling and BKFC bare knuckle: six league-parameterised `combat_*` tools, and `/raf` + `/bkfc` on the `call_api` allowlist.
561
+
543
562
  ### 0.4.7
544
563
 
545
564
  - Removed the unregistered tennis odds module and its README entries; `match_details` `sections:["odds"]` stays UFC-only and answers `NOT_IMPLEMENTED` for other games.
package/dist/envelope.js CHANGED
@@ -38,7 +38,7 @@ export const DEFAULT_RECOVER = {
38
38
  'Call api_health to see which games your plan includes',
39
39
  ],
40
40
  PATH_NOT_ALLOWED: [
41
- 'Use only allowlisted path prefixes: /health /lol /cs2 /dota2 /cod /ufc /fortnite',
41
+ 'Use only allowlisted path prefixes: /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis /raf /bkfc /bellator',
42
42
  'Prefer a curated tool from list_capabilities when one covers the outcome',
43
43
  ],
44
44
  NOT_IMPLEMENTED: [
@@ -18,6 +18,8 @@ game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | t
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
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.
21
+ - Bellator MMA is not on the enum either: combat_* tools with league bellator, or call_api /bellator/.... A closed 2014-2017 archive (Bellator 131-172) with official per-ROUND stats in UFC's shape ("13 of 18", round 0 = whole fight); no live, rankings or upcoming cards (combat_card "last" = most recent card). A fighter with a UFC record keeps the SAME slug as /ufc/fighters/{slug} (ufcFighterSlug). Never mix Bellator fights into UFC answers. Its stats predate the Free/Starter window: HISTORY_WINDOW_EXCEEDED means Pro, not a bad key.
22
+ - UFC includes DWCS and Road to UFC (2022-2026, all 19 cards with official round stats; slugs like road-to-ufc-2-7, "Road to UFC 2.7").
21
23
 
22
24
  ## Preferred tool order
23
25
 
@@ -1,17 +1,22 @@
1
1
  import { asRecord, clampInt, extractRows, fetchJson, gameNotIncludedHint } from '../client.js';
2
2
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
3
3
  import { boolSchema, limitSchema, stringSchema } from './types.js';
4
- export const COMBAT_LEAGUES = ['raf', 'bkfc'];
4
+ export const COMBAT_LEAGUES = ['raf', 'bkfc', 'bellator'];
5
5
  const LEAGUE_NAMES = {
6
6
  raf: 'Real American Freestyle (pro freestyle wrestling)',
7
7
  bkfc: 'Bare Knuckle Fighting Championship',
8
+ bellator: 'Bellator MMA',
8
9
  };
9
- const PEOPLE = { raf: 'athletes', bkfc: 'fighters' };
10
- const HISTORY = { raf: 'bouts', bkfc: 'fights' };
10
+ const ARCHIVES = {
11
+ bellator: 'Bellator is a closed archive (Bellator 131-172, November 2014 to February 2017)',
12
+ };
13
+ const PEOPLE = { raf: 'athletes', bkfc: 'fighters', bellator: 'fighters' };
14
+ const HISTORY = { raf: 'bouts', bkfc: 'fights', bellator: 'fights' };
11
15
  const leagueSchema = {
12
16
  type: 'string',
13
17
  enum: [...COMBAT_LEAGUES],
14
- description: 'League: "raf" (Real American Freestyle wrestling) or "bkfc" (Bare Knuckle Fighting Championship). Example: "bkfc".',
18
+ description: 'League: "raf" (Real American Freestyle wrestling), "bkfc" (Bare Knuckle Fighting Championship) or "bellator" ' +
19
+ '(Bellator MMA archive, 2014-2017). Example: "bkfc".',
15
20
  };
16
21
  function parseLeague(value) {
17
22
  const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
@@ -20,17 +25,25 @@ function parseLeague(value) {
20
25
  function leagueError(d, value) {
21
26
  return errorEnvelope({
22
27
  code: 'VALIDATION',
23
- message: `league must be raf or bkfc (got ${JSON.stringify(value ?? null)})`,
28
+ message: `league must be raf, bkfc or bellator (got ${JSON.stringify(value ?? null)})`,
24
29
  game: null,
25
30
  source: d.source,
26
31
  requestId: d.requestId,
27
32
  tookMs: Date.now() - d.started,
28
33
  });
29
34
  }
35
+ function isHistoryWindow(res) {
36
+ return res.status === 403 && asRecord(asRecord(res.data)?.error)?.code === 'HISTORY_WINDOW_EXCEEDED';
37
+ }
38
+ const HISTORY_WINDOW_RECOVER = [
39
+ 'Free reads 30 days and Starter 90 days of in-depth stats; Pro reads the full archive',
40
+ 'Summary sections (combat_card bouts, combat_athlete history) still return the records without stat blocks',
41
+ ];
30
42
  function httpError(d, league, res, what) {
31
43
  const body = asRecord(res.data);
32
44
  const err = asRecord(body?.error);
33
45
  return errorEnvelope({
46
+ ...(isHistoryWindow(res) ? { recover: HISTORY_WINDOW_RECOVER } : {}),
34
47
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
35
48
  message: (typeof err?.message === 'string' && err.message) || `${what} failed (HTTP ${res.status})`,
36
49
  game: league,
@@ -45,16 +58,41 @@ function payload(res) {
45
58
  const body = asRecord(res.data);
46
59
  return body && 'data' in body ? body.data : res.data;
47
60
  }
48
- function sectionFailure(section, res) {
61
+ function upstreamNotes(res) {
62
+ const meta = asRecord(asRecord(res.data)?.meta);
63
+ const out = {};
64
+ if (typeof meta?.note === 'string')
65
+ out.note = meta.note;
66
+ if (meta?.historyWindow)
67
+ out.historyWindow = meta.historyWindow;
68
+ return out;
69
+ }
70
+ function sectionFailure(section, res, league) {
49
71
  const body = asRecord(res.data);
50
72
  const err = asRecord(body?.error);
51
73
  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})`,
74
+ let message = err?.message || `${section} unavailable (HTTP ${res.status})`;
75
+ if (isHistoryWindow(res)) {
76
+ message =
77
+ `${section} is older than this key's history window (HISTORY_WINDOW_EXCEEDED: Free 30 days, Starter 90 days; ` +
78
+ `Pro reads the full archive)` + (league && ARCHIVES[league] ? `. ${ARCHIVES[league]}, so all of its stats need Pro` : '');
79
+ }
80
+ else if (res.status === 403) {
81
+ message = `${section} needs the Starter plan or above for this league (${err?.message || 'plan restriction'})`;
82
+ }
83
+ return partialFromRejection(section, { code, httpStatus: res.status, message });
84
+ }
85
+ function archiveGap(d, league, message, recover, calls = 0) {
86
+ return errorEnvelope({
87
+ code: 'NOT_IMPLEMENTED',
88
+ message,
89
+ game: league,
90
+ source: d.source,
91
+ requestId: d.requestId,
92
+ tookMs: Date.now() - d.started,
93
+ upstreamCalls: calls,
94
+ retryable: false,
95
+ recover,
58
96
  });
59
97
  }
60
98
  async function resolvePerson(ctx, league, input) {
@@ -116,15 +154,17 @@ function begin(source) {
116
154
  }
117
155
  export const combatEvents = {
118
156
  name: 'combat_events',
119
- description: `RAF wrestling or BKFC bare-knuckle events: upcoming cards or recent results.
157
+ description: `RAF wrestling, BKFC bare-knuckle or Bellator MMA events: upcoming cards or recent results.
120
158
 
121
159
  When to use:
122
- - "When is the next RAF / BKFC card?", "recent BKFC results", schedule pages
160
+ - "When is the next RAF / BKFC card?", "recent BKFC results", "Bellator cards from 2015", schedule pages
123
161
  - Getting an event slug for combat_card
124
162
 
125
163
  Each event carries slug, name, date, local start time + timezone, venue/location, status and bout counts.
164
+ Bellator is a closed archive (Bellator 131-172, Nov 2014 - Feb 2017): "when" defaults to "recent" (newest card
165
+ first), and "upcoming" returns an empty list with data.note instead of failing.
126
166
 
127
- Prefer over: call_api /raf/events or /bkfc/events.
167
+ Prefer over: call_api /raf/events, /bkfc/events or /bellator/events.
128
168
  Do not use when: you already have the event slug and want its bouts (combat_card).
129
169
 
130
170
  Parallel-safe: yes. Upstream cost: 1.
@@ -139,7 +179,8 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
139
179
  type: 'string',
140
180
  enum: ['upcoming', 'recent'],
141
181
  default: 'upcoming',
142
- description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Example: "recent".',
182
+ description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Default "upcoming" ' +
183
+ '("recent" for bellator, an archive). Example: "recent".',
143
184
  },
144
185
  limit: limitSchema({ default: 10, max: 50, description: 'Max events (default 10, max 50).' }),
145
186
  },
@@ -149,14 +190,28 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
149
190
  const league = parseLeague(args.league);
150
191
  if (!league)
151
192
  return leagueError(d, args.league);
152
- const when = args.when === 'recent' ? 'recent' : 'upcoming';
193
+ const archive = ARCHIVES[league];
194
+ const when = args.when === 'recent' || args.when === 'upcoming' ? args.when : archive ? 'recent' : 'upcoming';
153
195
  const limit = clampInt(args.limit, 10, 1, 50);
196
+ if (archive && when === 'upcoming') {
197
+ return successEnvelope({
198
+ data: {
199
+ league, leagueName: LEAGUE_NAMES[league], when, count: 0, events: [],
200
+ note: `${archive}: there are no upcoming cards. Use when "recent" for its cards, newest first.`,
201
+ },
202
+ game: league,
203
+ source: d.source,
204
+ requestId: d.requestId,
205
+ tookMs: Date.now() - d.started,
206
+ upstreamCalls: 0,
207
+ });
208
+ }
154
209
  const res = await fetchJson(ctx, `/${league}/events/${when}`, { query: { limit } });
155
210
  if (!res.ok)
156
211
  return httpError(d, league, res, `${league} ${when} events`);
157
212
  const events = extractRows(res.data).slice(0, limit);
158
213
  return successEnvelope({
159
- data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events },
214
+ data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events, ...upstreamNotes(res) },
160
215
  game: league,
161
216
  source: d.source,
162
217
  requestId: d.requestId,
@@ -168,15 +223,18 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
168
223
  };
169
224
  export const combatCard = {
170
225
  name: 'combat_card',
171
- description: `COMPOSITE fight card for one RAF or BKFC event: event identity + bouts in running order with results.
226
+ description: `COMPOSITE fight card for one RAF, BKFC or Bellator event: event identity + bouts in running order with results.
172
227
 
173
228
  When to use:
174
- - "RAF 14 card", "BKFC 95 results", "who won the main event at the last BKFC?"
229
+ - "RAF 14 card", "BKFC 95 results", "Bellator 142 card", "who won the main event at the last BKFC?"
175
230
  - Event pages and results pages
176
231
 
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).
232
+ event accepts a slug (e.g. "raf13", "bkfc-95-newark", "bellator-142" or "142") or "next" / "last". Bouts come main
233
+ event first; each carries weight class, title stakes, both sides (athletes for RAF, red/blue fighters for BKFC with
234
+ punch totals, red/blue fighters for Bellator with UFC-format totals and ufcFighterSlug), and once finished the winner
235
+ and result (RAF: score, takedowns, method; BKFC/Bellator: method, round, time).
236
+ Bellator is an archive: event defaults to "last" (its most recent card, Bellator 172), and "next" is a
237
+ non-retryable NOT_IMPLEMENTED (there are no upcoming cards).
180
238
 
181
239
  Prefer over: combat_events + call_api per bout.
182
240
  Do not use when: you need one bout's period/round breakdown (combat_bout).
@@ -189,7 +247,7 @@ Example: { "league": "raf", "event": "last" }`,
189
247
  required: ['league'],
190
248
  properties: {
191
249
  league: leagueSchema,
192
- event: stringSchema('Event slug, or "next" / "last" (default "next"). Example: "raf13".', 'next'),
250
+ event: stringSchema('Event slug, or "next" / "last" (default "next"; "last" for bellator). Example: "raf13".', 'next'),
193
251
  limit: limitSchema({ default: 30, max: 40, description: 'Max bouts on the card (default 30, max 40).' }),
194
252
  },
195
253
  },
@@ -199,8 +257,14 @@ Example: { "league": "raf", "event": "last" }`,
199
257
  if (!league)
200
258
  return leagueError(d, args.league);
201
259
  const limit = clampInt(args.limit, 30, 1, 40);
202
- let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : 'next';
260
+ const archive = ARCHIVES[league];
261
+ let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : archive ? 'last' : 'next';
203
262
  let calls = 0;
263
+ if (archive && slug === 'next') {
264
+ return archiveGap(d, league, `${archive}: there is no next card.`, [
265
+ `Use event "last" for the most recent ${league} card, or a slug from combat_events { league: "${league}", when: "recent" }`,
266
+ ]);
267
+ }
204
268
  if (slug === 'next' || slug === 'last') {
205
269
  const which = slug === 'next' ? 'upcoming' : 'recent';
206
270
  const list = await fetchJson(ctx, `/${league}/events/${which}`, { query: { limit: 1 } });
@@ -209,20 +273,24 @@ Example: { "league": "raf", "event": "last" }`,
209
273
  return httpError(d, league, list, `${league} ${which} events`);
210
274
  const first = asRecord(extractRows(list.data)[0]);
211
275
  if (!first?.slug) {
276
+ const windowed = !!upstreamNotes(list).historyWindow;
212
277
  return errorEnvelope({
213
278
  code: 'NOT_FOUND',
214
- message: `No ${which} ${league.toUpperCase()} event`,
279
+ message: windowed
280
+ ? `No ${which} ${league.toUpperCase()} event inside this key's history window (meta.historyWindow); pass an event slug instead`
281
+ : `No ${which} ${league.toUpperCase()} event`,
215
282
  game: league,
216
283
  source: d.source,
217
284
  requestId: d.requestId,
218
285
  tookMs: Date.now() - d.started,
219
286
  upstreamCalls: calls,
287
+ ...(windowed ? { retryable: false, details: { historyWindow: upstreamNotes(list).historyWindow } } : {}),
220
288
  });
221
289
  }
222
290
  slug = String(first.slug);
223
291
  }
224
292
  const [ev, bouts] = await Promise.all([
225
- fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`),
293
+ fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`, league === 'bellator' ? { query: { includeBouts: 'false' } } : undefined),
226
294
  fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}/bouts`),
227
295
  ]);
228
296
  calls += 2;
@@ -235,10 +303,10 @@ Example: { "league": "raf", "event": "last" }`,
235
303
  rows = Array.isArray(p) ? p : extractRows(asRecord(p)?.bouts ?? p);
236
304
  }
237
305
  else {
238
- partial.push(sectionFailure('bouts', bouts));
306
+ partial.push(sectionFailure('bouts', bouts, league));
239
307
  }
240
308
  const eventData = asRecord(payload(ev)) ?? {};
241
- const { bouts: _ignored, ...event } = eventData;
309
+ const { bouts: _ignored, card: _card, ...event } = eventData;
242
310
  return successEnvelope({
243
311
  data: { league, event, boutCount: rows.length, bouts: rows.slice(0, limit) },
244
312
  game: league,
@@ -256,13 +324,18 @@ Example: { "league": "raf", "event": "last" }`,
256
324
  };
257
325
  export const combatBout = {
258
326
  name: 'combat_bout',
259
- description: `One RAF or BKFC bout in full, with its scoring breakdown.
327
+ description: `One RAF, BKFC or Bellator bout in full, with its scoring breakdown.
260
328
 
261
329
  RAF: winner, method (decision / tech fall / pin / injury), final score, takedowns, and per-PERIOD scores.
262
330
  BKFC: winner, method, round, time, and per-ROUND punch stats for red and blue (landed/thrown, head/body,
263
331
  distance/clinch, knockdowns). BKFC round stats need the Starter plan; on Free they come back as a partial note.
332
+ Bellator: winner, method, round, time, finish (position / submission), referee, judges' scores, each corner's
333
+ whole-fight totals, and per-ROUND official stats in UFC's format (significantStrikes "13 of 18", totalStrikes,
334
+ takedowns, head/body/leg, distance/clinch/ground, knockdowns, submissionAttempts, reversals, controlTime "1:42").
335
+ The archive is 2014-2017, older than the Free/Starter history window: on those plans rounds come back as a
336
+ HISTORY_WINDOW_EXCEEDED partial note while the result still returns.
264
337
 
265
- Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington").
338
+ Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington", "bellator-5600").
266
339
 
267
340
  Parallel-safe: yes. Upstream cost: 1–2.
268
341
  Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cisneros--ryan-petersen" }`,
@@ -272,8 +345,8 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
272
345
  required: ['league', 'boutId'],
273
346
  properties: {
274
347
  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),
348
+ boutId: stringSchema('Bout id from combat_card / combat_athlete (Bellator: "bellator-5600"). Example: "raf13--belal-muhammad--colby-covington".'),
349
+ includeBreakdown: boolSchema('Include per-period (RAF) / per-round (BKFC, Bellator) breakdown (default true).', true),
277
350
  },
278
351
  },
279
352
  handler: async (args, ctx) => {
@@ -301,11 +374,13 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
301
374
  if (parts.ok)
302
375
  data[sub] = asRecord(payload(parts))?.[sub] ?? payload(parts);
303
376
  else
304
- partial.push(sectionFailure(sub, parts));
377
+ partial.push(sectionFailure(sub, parts, league));
305
378
  }
306
379
  const b = asRecord(data.bout);
307
380
  if (league === 'bkfc' && b && 'rounds' in b && data.rounds)
308
381
  delete b.rounds;
382
+ if (league === 'bellator' && b && 'roundStats' in b && (data.rounds || !breakdown))
383
+ delete b.roundStats;
309
384
  return successEnvelope({
310
385
  data,
311
386
  game: league,
@@ -320,15 +395,19 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
320
395
  };
321
396
  export const combatAthlete = {
322
397
  name: 'combat_athlete',
323
- description: `COMPOSITE RAF athlete / BKFC fighter page: profile + recent bouts + career stats.
398
+ description: `COMPOSITE RAF athlete / BKFC or Bellator fighter page: profile + recent bouts + career stats.
324
399
 
325
400
  When to use:
326
- - "Kyle Snyder's RAF record", "Arlovski BKFC stats", athlete/fighter profile pages
401
+ - "Kyle Snyder's RAF record", "Arlovski BKFC stats", "Tito Ortiz in Bellator", athlete/fighter profile pages
327
402
 
328
403
  athlete accepts a slug ("kyle-snyder") or a name ("Andrei Arlovski"); names are resolved through the league's
329
404
  search, and several matches return AMBIGUOUS_ENTITY with candidates. Profile carries record, division, nickname,
330
405
  country, official headshot (+ its credit — show it with the image). Career stats need the Starter plan; on Free
331
406
  they come back as a partial note while profile and history still return.
407
+ Bellator: profile is bio (DOB, height, reach, stance, hometown) + Bellator-only record (2014-2017 archive, not the
408
+ whole career); stats are career totals in UFC's format. A fighter we also hold a UFC record for keeps the SAME slug
409
+ as /ufc/fighters/{slug}: data.ufcFighterSlug is set then (a UFC slug is accepted here too). Keep the two records
410
+ apart: Bellator fights never belong in a UFC answer.
332
411
 
333
412
  Prefer over: call_api profile + history + stats separately.
334
413
 
@@ -370,17 +449,21 @@ Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
370
449
  return httpError(d, league, profile, `${league} ${PEOPLE[league]} ${r.slug}`);
371
450
  const partial = [];
372
451
  const data = { league, slug: r.slug, profile: payload(profile) };
452
+ if (league === 'bellator') {
453
+ const ufc = asRecord(asRecord(data.profile)?.ufc);
454
+ data.ufcFighterSlug = typeof ufc?.ufcFighterSlug === 'string' ? ufc.ufcFighterSlug : null;
455
+ }
373
456
  if (history) {
374
457
  if (history.ok)
375
458
  data.history = extractRows(history.data).slice(0, historyLimit);
376
459
  else
377
- partial.push(sectionFailure('history', history));
460
+ partial.push(sectionFailure('history', history, league));
378
461
  }
379
462
  if (stats) {
380
463
  if (stats.ok)
381
464
  data.stats = payload(stats);
382
465
  else
383
- partial.push(sectionFailure('stats', stats));
466
+ partial.push(sectionFailure('stats', stats, league));
384
467
  }
385
468
  return successEnvelope({
386
469
  data,
@@ -396,9 +479,10 @@ Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
396
479
  };
397
480
  export const combatH2h = {
398
481
  name: 'combat_h2h',
399
- description: `Head-to-head between two RAF athletes or two BKFC fighters: record and every meeting.
482
+ description: `Head-to-head between two RAF athletes, two BKFC fighters or two Bellator fighters: record and every meeting.
400
483
 
401
- Accepts slugs or names for both sides (names resolve through the league's search).
484
+ Accepts slugs or names for both sides (names resolve through the league's search). Bellator meetings are only
485
+ the ones on Bellator cards (2014-2017); UFC meetings between the same two are a UFC question (head_to_head).
402
486
 
403
487
  Parallel-safe: yes. Upstream cost: 1–3.
404
488
  Example: { "league": "bkfc", "athleteA": "ryan-petersen", "athleteB": "gregoris-cisneros" }`,
@@ -453,6 +537,8 @@ export const combatRankings = {
453
537
  BKFC: omit division for every division's ranked list (champion, interim champion, ranked fighters), or pass one
454
538
  (e.g. "heavyweight", "womens-flyweight"). RAF has no ranked lists; it returns every title and its holder, or one
455
539
  division's detail when division is given (e.g. "men-heavyweight").
540
+ Bellator has no rankings (a 2014-2017 archive): league "bellator" returns a non-retryable NOT_IMPLEMENTED.
541
+ Never answer with UFC rankings instead.
456
542
 
457
543
  Parallel-safe: yes. Upstream cost: 1.
458
544
  Example: { "league": "bkfc", "division": "heavyweight" }`,
@@ -470,6 +556,13 @@ Example: { "league": "bkfc", "division": "heavyweight" }`,
470
556
  const league = parseLeague(args.league);
471
557
  if (!league)
472
558
  return leagueError(d, args.league);
559
+ if (league === 'bellator') {
560
+ return archiveGap(d, league, `Bellator has no rankings or champions lists: ${ARCHIVES.bellator}, and the API serves its results only.`, [
561
+ 'Do not retry: no Bellator rankings exist on the API',
562
+ 'Title fights: call_api /bellator/bouts with query { "title": "true" }; results: combat_events { league: "bellator", when: "recent" }',
563
+ 'Never substitute UFC rankings for Bellator',
564
+ ]);
565
+ }
473
566
  const division = typeof args.division === 'string' ? args.division.trim().toLowerCase() : '';
474
567
  const path = league === 'bkfc'
475
568
  ? division ? `/bkfc/rankings/${encodeURIComponent(division)}` : '/bkfc/rankings'
@@ -6,19 +6,19 @@ const COMBAT_CATALOG = [
6
6
  {
7
7
  name: 'combat_events',
8
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',
9
+ outcome: 'RAF wrestling / BKFC bare-knuckle / Bellator MMA events: upcoming cards or recent results, with date, local time and venue (Bellator: archive, upcoming is empty with a note)',
10
10
  parallelSafe: true,
11
- games: ['raf', 'bkfc'],
11
+ games: ['raf', 'bkfc', 'bellator'],
12
12
  jobs: ['schedule'],
13
13
  exampleArgs: { league: 'bkfc', when: 'upcoming', limit: 5 },
14
- preferOver: ['call_api /raf/events or /bkfc/events'],
14
+ preferOver: ['call_api /raf/events, /bkfc/events or /bellator/events'],
15
15
  },
16
16
  {
17
17
  name: 'combat_card',
18
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"',
19
+ outcome: 'One RAF/BKFC/Bellator event card in running order with results; event = slug, "next" or "last" (Bellator: "last" = most recent archived card, no "next")',
20
20
  parallelSafe: true,
21
- games: ['raf', 'bkfc'],
21
+ games: ['raf', 'bkfc', 'bellator'],
22
22
  jobs: ['event_card', 'schedule', 'media'],
23
23
  exampleArgs: { league: 'raf', event: 'last' },
24
24
  preferOver: ['combat_events + call_api per bout'],
@@ -27,9 +27,9 @@ const COMBAT_CATALOG = [
27
27
  name: 'combat_bout',
28
28
  doNotUse: 'Whole card → combat_card',
29
29
  preferOver: [],
30
- outcome: 'One bout in full: RAF per-period scores / BKFC per-round punch stats (Starter)',
30
+ outcome: 'One bout in full: RAF per-period scores / BKFC per-round punch stats (Starter) / Bellator per-round official stats in UFC format (Pro: the archive is older than the Free/Starter window)',
31
31
  parallelSafe: true,
32
- games: ['raf', 'bkfc'],
32
+ games: ['raf', 'bkfc', 'bellator'],
33
33
  jobs: ['match_page'],
34
34
  exampleArgs: { league: 'raf', boutId: 'raf13--belal-muhammad--colby-covington' },
35
35
  },
@@ -37,9 +37,9 @@ const COMBAT_CATALOG = [
37
37
  name: 'combat_athlete',
38
38
  doNotUse: "Two athletes' meetings → combat_h2h",
39
39
  preferOver: [],
40
- outcome: 'RAF athlete / BKFC fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name',
40
+ outcome: 'RAF athlete / BKFC or Bellator fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name; Bellator fighters share the UFC slug (ufcFighterSlug)',
41
41
  parallelSafe: true,
42
- games: ['raf', 'bkfc'],
42
+ games: ['raf', 'bkfc', 'bellator'],
43
43
  jobs: ['player_form', 'media'],
44
44
  exampleArgs: { league: 'raf', athlete: 'Kyle Snyder', historyLimit: 5 },
45
45
  },
@@ -47,19 +47,19 @@ const COMBAT_CATALOG = [
47
47
  name: 'combat_h2h',
48
48
  doNotUse: "One athlete's whole history → combat_athlete",
49
49
  preferOver: [],
50
- outcome: 'Head-to-head between two RAF athletes or two BKFC fighters',
50
+ outcome: 'Head-to-head between two RAF athletes, two BKFC fighters or two Bellator fighters',
51
51
  parallelSafe: true,
52
- games: ['raf', 'bkfc'],
52
+ games: ['raf', 'bkfc', 'bellator'],
53
53
  jobs: ['h2h', 'preview'],
54
54
  exampleArgs: { league: 'bkfc', athleteA: 'ryan-petersen', athleteB: 'gregoris-cisneros' },
55
55
  },
56
56
  {
57
57
  name: 'combat_rankings',
58
- doNotUse: 'UFC rankings → standings / rankings tools (RAF/BKFC never appear in UFC answers)',
58
+ doNotUse: 'UFC rankings → standings / rankings tools (RAF/BKFC/Bellator never appear in UFC answers); Bellator has no rankings (NOT_IMPLEMENTED)',
59
59
  preferOver: [],
60
60
  outcome: 'RAF champions per division / BKFC official rankings and champions',
61
61
  parallelSafe: true,
62
- games: ['raf', 'bkfc'],
62
+ games: ['raf', 'bkfc', 'bellator'],
63
63
  jobs: ['standings'],
64
64
  exampleArgs: { league: 'bkfc', division: 'heavyweight' },
65
65
  },
@@ -547,7 +547,14 @@ const GAMES_META = [
547
547
  { id: 'cs2', label: 'Counter-Strike 2', depth: 'deep', notes: 'Prefixed ids; live path /cs2/live; trends/rankings' },
548
548
  { id: 'dota2', label: 'Dota 2', depth: 'thin', notes: 'Numeric ids; radar; no standings/roster endpoints' },
549
549
  { id: 'cod', label: 'Call of Duty (CDL)', depth: 'medium', notes: 'Org slugs; CDL standings; UUID match ids' },
550
- { id: 'ufc', label: 'UFC / MMA', depth: 'medium', notes: 'Fighter slugs; boutId; live + rankings' },
550
+ {
551
+ id: 'ufc',
552
+ label: 'UFC / MMA',
553
+ depth: 'medium',
554
+ notes: 'Fighter slugs; boutId; live + rankings. Includes Dana White\'s Contender Series and Road to UFC (2022-2026, all 19 cards ' +
555
+ 'with official round stats; event slugs like road-to-ufc-2-7, titles "Road to UFC 2.7"). Bellator is NOT UFC: use the ' +
556
+ 'combat_* tools with league bellator, and never mix its fights into UFC answers.',
557
+ },
551
558
  ];
552
559
  export const listCapabilities = {
553
560
  name: 'list_capabilities',
@@ -789,6 +796,7 @@ const ALLOWLIST_PREFIXES = [
789
796
  '/tennis',
790
797
  '/raf',
791
798
  '/bkfc',
799
+ '/bellator',
792
800
  '/openapi.json',
793
801
  ];
794
802
  function pathAllowed(path) {
@@ -812,7 +820,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
812
820
 
813
821
  Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
814
822
 
815
- Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc.
823
+ Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc, /bellator.
816
824
  Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
817
825
 
818
826
  Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
@@ -873,7 +881,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
873
881
  source: 'call_api',
874
882
  requestId,
875
883
  tookMs: Date.now() - started,
876
- hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis',
884
+ hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis /raf /bkfc /bellator',
877
885
  });
878
886
  }
879
887
  let query = {};
@@ -974,10 +982,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
974
982
  const started = Date.now();
975
983
  const requestId = newRequestId();
976
984
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
977
- if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc)$/)) {
985
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator)$/)) {
978
986
  return errorEnvelope({
979
987
  code: 'UNSUPPORTED_GAME',
980
- message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc`,
988
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator`,
981
989
  game: null,
982
990
  source: 'list_routes',
983
991
  requestId,
@@ -1000,7 +1008,7 @@ Example: { "game": "ufc", "q": "rankings" }`,
1000
1008
  httpStatus: res.status,
1001
1009
  recover: [
1002
1010
  'Call list_capabilities for curated tools that need no route knowledge',
1003
- 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
1011
+ 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc, /bellator',
1004
1012
  ],
1005
1013
  });
1006
1014
  }
package/package.json CHANGED
@@ -1,51 +1,52 @@
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
+ {
2
+ "name": "cito-mcp",
3
+ "version": "0.4.9",
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
+ "bellator",
22
+ "tennis",
23
+ "live-scores",
24
+ "sports-data",
25
+ "esports-api"
26
+ ],
27
+ "bin": {
28
+ "cito-mcp": "dist/index.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "test": "tsx --test src/**/*.test.ts src/*.test.ts",
40
+ "start": "node dist/index.js",
41
+ "prepublishOnly": "npm run build && npm test && npm run smoke",
42
+ "smoke": "node scripts/smoke.mjs"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "1.29.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.0",
49
+ "tsx": "^4.23.1",
50
+ "typescript": "^5.9.2"
51
+ }
52
+ }