cito-mcp 0.4.7 → 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**. 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: [
@@ -17,6 +17,9 @@ 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.
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").
20
23
 
21
24
  ## Preferred tool order
22
25
 
@@ -0,0 +1,584 @@
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', 'bellator'];
5
+ const LEAGUE_NAMES = {
6
+ raf: 'Real American Freestyle (pro freestyle wrestling)',
7
+ bkfc: 'Bare Knuckle Fighting Championship',
8
+ bellator: 'Bellator MMA',
9
+ };
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' };
15
+ const leagueSchema = {
16
+ type: 'string',
17
+ enum: [...COMBAT_LEAGUES],
18
+ description: 'League: "raf" (Real American Freestyle wrestling), "bkfc" (Bare Knuckle Fighting Championship) or "bellator" ' +
19
+ '(Bellator MMA archive, 2014-2017). Example: "bkfc".',
20
+ };
21
+ function parseLeague(value) {
22
+ const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
23
+ return COMBAT_LEAGUES.includes(v) ? v : null;
24
+ }
25
+ function leagueError(d, value) {
26
+ return errorEnvelope({
27
+ code: 'VALIDATION',
28
+ message: `league must be raf, bkfc or bellator (got ${JSON.stringify(value ?? null)})`,
29
+ game: null,
30
+ source: d.source,
31
+ requestId: d.requestId,
32
+ tookMs: Date.now() - d.started,
33
+ });
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
+ ];
42
+ function httpError(d, league, res, what) {
43
+ const body = asRecord(res.data);
44
+ const err = asRecord(body?.error);
45
+ return errorEnvelope({
46
+ ...(isHistoryWindow(res) ? { recover: HISTORY_WINDOW_RECOVER } : {}),
47
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
48
+ message: (typeof err?.message === 'string' && err.message) || `${what} failed (HTTP ${res.status})`,
49
+ game: league,
50
+ source: d.source,
51
+ httpStatus: res.status,
52
+ requestId: d.requestId,
53
+ tookMs: Date.now() - d.started,
54
+ rateLimit: res.headers,
55
+ });
56
+ }
57
+ function payload(res) {
58
+ const body = asRecord(res.data);
59
+ return body && 'data' in body ? body.data : res.data;
60
+ }
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) {
71
+ const body = asRecord(res.data);
72
+ const err = asRecord(body?.error);
73
+ const code = mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) });
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,
96
+ });
97
+ }
98
+ async function resolvePerson(ctx, league, input) {
99
+ const raw = input.trim();
100
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(raw)) {
101
+ const probe = await fetchJson(ctx, `/${league}/${PEOPLE[league]}/${encodeURIComponent(raw)}`);
102
+ if (probe.ok)
103
+ return { slug: raw };
104
+ if (probe.status !== 404)
105
+ return { error: probe };
106
+ }
107
+ const res = await fetchJson(ctx, `/${league}/search`, { query: { q: raw.replace(/-/g, ' '), limit: 10 } });
108
+ if (!res.ok)
109
+ return { error: res };
110
+ const hits = extractRows(res.data)
111
+ .map((r) => asRecord(r))
112
+ .filter((r) => !!r && (r.type === 'athlete' || r.type === 'fighter'));
113
+ if (!hits.length)
114
+ return { notFound: true };
115
+ const exact = hits.filter((h) => String(h.name ?? '').toLowerCase() === raw.toLowerCase());
116
+ if (exact.length === 1)
117
+ return { slug: String(exact[0].slug) };
118
+ if (hits.length === 1)
119
+ return { slug: String(hits[0].slug) };
120
+ return { candidates: hits.slice(0, 8) };
121
+ }
122
+ async function resolveOrFail(d, ctx, league, input, argName) {
123
+ const r = await resolvePerson(ctx, league, input);
124
+ if ('slug' in r)
125
+ return r;
126
+ if ('error' in r)
127
+ return { fail: httpError(d, league, r.error, `${argName} lookup`) };
128
+ if ('candidates' in r) {
129
+ return {
130
+ fail: errorEnvelope({
131
+ code: 'AMBIGUOUS_ENTITY',
132
+ message: `"${input}" matches several ${PEOPLE[league]}; pass one slug from details.candidates`,
133
+ game: league,
134
+ source: d.source,
135
+ requestId: d.requestId,
136
+ tookMs: Date.now() - d.started,
137
+ details: { candidates: r.candidates },
138
+ }),
139
+ };
140
+ }
141
+ return {
142
+ fail: errorEnvelope({
143
+ code: 'NOT_FOUND',
144
+ message: `No ${league.toUpperCase()} ${PEOPLE[league].slice(0, -1)} matches "${input}"`,
145
+ game: league,
146
+ source: d.source,
147
+ requestId: d.requestId,
148
+ tookMs: Date.now() - d.started,
149
+ }),
150
+ };
151
+ }
152
+ function begin(source) {
153
+ return { started: Date.now(), requestId: newRequestId(), source };
154
+ }
155
+ export const combatEvents = {
156
+ name: 'combat_events',
157
+ description: `RAF wrestling, BKFC bare-knuckle or Bellator MMA events: upcoming cards or recent results.
158
+
159
+ When to use:
160
+ - "When is the next RAF / BKFC card?", "recent BKFC results", "Bellator cards from 2015", schedule pages
161
+ - Getting an event slug for combat_card
162
+
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.
166
+
167
+ Prefer over: call_api /raf/events, /bkfc/events or /bellator/events.
168
+ Do not use when: you already have the event slug and want its bouts (combat_card).
169
+
170
+ Parallel-safe: yes. Upstream cost: 1.
171
+ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
172
+ inputSchema: {
173
+ type: 'object',
174
+ additionalProperties: false,
175
+ required: ['league'],
176
+ properties: {
177
+ league: leagueSchema,
178
+ when: {
179
+ type: 'string',
180
+ enum: ['upcoming', 'recent'],
181
+ default: 'upcoming',
182
+ description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Default "upcoming" ' +
183
+ '("recent" for bellator, an archive). Example: "recent".',
184
+ },
185
+ limit: limitSchema({ default: 10, max: 50, description: 'Max events (default 10, max 50).' }),
186
+ },
187
+ },
188
+ handler: async (args, ctx) => {
189
+ const d = begin('combat_events');
190
+ const league = parseLeague(args.league);
191
+ if (!league)
192
+ return leagueError(d, args.league);
193
+ const archive = ARCHIVES[league];
194
+ const when = args.when === 'recent' || args.when === 'upcoming' ? args.when : archive ? 'recent' : 'upcoming';
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
+ }
209
+ const res = await fetchJson(ctx, `/${league}/events/${when}`, { query: { limit } });
210
+ if (!res.ok)
211
+ return httpError(d, league, res, `${league} ${when} events`);
212
+ const events = extractRows(res.data).slice(0, limit);
213
+ return successEnvelope({
214
+ data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events, ...upstreamNotes(res) },
215
+ game: league,
216
+ source: d.source,
217
+ requestId: d.requestId,
218
+ tookMs: Date.now() - d.started,
219
+ upstreamCalls: 1,
220
+ rateLimit: res.headers,
221
+ });
222
+ },
223
+ };
224
+ export const combatCard = {
225
+ name: 'combat_card',
226
+ description: `COMPOSITE fight card for one RAF, BKFC or Bellator event: event identity + bouts in running order with results.
227
+
228
+ When to use:
229
+ - "RAF 14 card", "BKFC 95 results", "Bellator 142 card", "who won the main event at the last BKFC?"
230
+ - Event pages and results pages
231
+
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).
238
+
239
+ Prefer over: combat_events + call_api per bout.
240
+ Do not use when: you need one bout's period/round breakdown (combat_bout).
241
+
242
+ Parallel-safe: yes. Upstream cost: 2–3.
243
+ Example: { "league": "raf", "event": "last" }`,
244
+ inputSchema: {
245
+ type: 'object',
246
+ additionalProperties: false,
247
+ required: ['league'],
248
+ properties: {
249
+ league: leagueSchema,
250
+ event: stringSchema('Event slug, or "next" / "last" (default "next"; "last" for bellator). Example: "raf13".', 'next'),
251
+ limit: limitSchema({ default: 30, max: 40, description: 'Max bouts on the card (default 30, max 40).' }),
252
+ },
253
+ },
254
+ handler: async (args, ctx) => {
255
+ const d = begin('combat_card');
256
+ const league = parseLeague(args.league);
257
+ if (!league)
258
+ return leagueError(d, args.league);
259
+ const limit = clampInt(args.limit, 30, 1, 40);
260
+ const archive = ARCHIVES[league];
261
+ let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : archive ? 'last' : 'next';
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
+ }
268
+ if (slug === 'next' || slug === 'last') {
269
+ const which = slug === 'next' ? 'upcoming' : 'recent';
270
+ const list = await fetchJson(ctx, `/${league}/events/${which}`, { query: { limit: 1 } });
271
+ calls += 1;
272
+ if (!list.ok)
273
+ return httpError(d, league, list, `${league} ${which} events`);
274
+ const first = asRecord(extractRows(list.data)[0]);
275
+ if (!first?.slug) {
276
+ const windowed = !!upstreamNotes(list).historyWindow;
277
+ return errorEnvelope({
278
+ code: 'NOT_FOUND',
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`,
282
+ game: league,
283
+ source: d.source,
284
+ requestId: d.requestId,
285
+ tookMs: Date.now() - d.started,
286
+ upstreamCalls: calls,
287
+ ...(windowed ? { retryable: false, details: { historyWindow: upstreamNotes(list).historyWindow } } : {}),
288
+ });
289
+ }
290
+ slug = String(first.slug);
291
+ }
292
+ const [ev, bouts] = await Promise.all([
293
+ fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`, league === 'bellator' ? { query: { includeBouts: 'false' } } : undefined),
294
+ fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}/bouts`),
295
+ ]);
296
+ calls += 2;
297
+ if (!ev.ok)
298
+ return httpError(d, league, ev, `${league} event ${slug}`);
299
+ const partial = [];
300
+ let rows = [];
301
+ if (bouts.ok) {
302
+ const p = payload(bouts);
303
+ rows = Array.isArray(p) ? p : extractRows(asRecord(p)?.bouts ?? p);
304
+ }
305
+ else {
306
+ partial.push(sectionFailure('bouts', bouts, league));
307
+ }
308
+ const eventData = asRecord(payload(ev)) ?? {};
309
+ const { bouts: _ignored, card: _card, ...event } = eventData;
310
+ return successEnvelope({
311
+ data: { league, event, boutCount: rows.length, bouts: rows.slice(0, limit) },
312
+ game: league,
313
+ source: d.source,
314
+ requestId: d.requestId,
315
+ tookMs: Date.now() - d.started,
316
+ upstreamCalls: calls,
317
+ rateLimit: bouts.headers ?? ev.headers,
318
+ partial,
319
+ truncated: rows.length > limit
320
+ ? { applied: true, reason: 'section_item_cap', dropped: { itemsFrom: limit, itemsTo: rows.length }, recover: ['Raise limit (max 40)'] }
321
+ : null,
322
+ });
323
+ },
324
+ };
325
+ export const combatBout = {
326
+ name: 'combat_bout',
327
+ description: `One RAF, BKFC or Bellator bout in full, with its scoring breakdown.
328
+
329
+ RAF: winner, method (decision / tech fall / pin / injury), final score, takedowns, and per-PERIOD scores.
330
+ BKFC: winner, method, round, time, and per-ROUND punch stats for red and blue (landed/thrown, head/body,
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.
337
+
338
+ Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington", "bellator-5600").
339
+
340
+ Parallel-safe: yes. Upstream cost: 1–2.
341
+ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cisneros--ryan-petersen" }`,
342
+ inputSchema: {
343
+ type: 'object',
344
+ additionalProperties: false,
345
+ required: ['league', 'boutId'],
346
+ properties: {
347
+ league: leagueSchema,
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),
350
+ },
351
+ },
352
+ handler: async (args, ctx) => {
353
+ const d = begin('combat_bout');
354
+ const league = parseLeague(args.league);
355
+ if (!league)
356
+ return leagueError(d, args.league);
357
+ const boutId = typeof args.boutId === 'string' ? args.boutId.trim() : '';
358
+ if (!boutId) {
359
+ return errorEnvelope({ code: 'VALIDATION', message: 'boutId is required', game: league, source: d.source,
360
+ requestId: d.requestId, tookMs: Date.now() - d.started });
361
+ }
362
+ const id = encodeURIComponent(boutId);
363
+ const breakdown = args.includeBreakdown !== false;
364
+ const sub = league === 'raf' ? 'periods' : 'rounds';
365
+ const [bout, parts] = await Promise.all([
366
+ fetchJson(ctx, `/${league}/bouts/${id}`),
367
+ breakdown ? fetchJson(ctx, `/${league}/bouts/${id}/${sub}`) : Promise.resolve(null),
368
+ ]);
369
+ if (!bout.ok)
370
+ return httpError(d, league, bout, `${league} bout ${boutId}`);
371
+ const partial = [];
372
+ const data = { league, bout: payload(bout) };
373
+ if (parts) {
374
+ if (parts.ok)
375
+ data[sub] = asRecord(payload(parts))?.[sub] ?? payload(parts);
376
+ else
377
+ partial.push(sectionFailure(sub, parts, league));
378
+ }
379
+ const b = asRecord(data.bout);
380
+ if (league === 'bkfc' && b && 'rounds' in b && data.rounds)
381
+ delete b.rounds;
382
+ if (league === 'bellator' && b && 'roundStats' in b && (data.rounds || !breakdown))
383
+ delete b.roundStats;
384
+ return successEnvelope({
385
+ data,
386
+ game: league,
387
+ source: d.source,
388
+ requestId: d.requestId,
389
+ tookMs: Date.now() - d.started,
390
+ upstreamCalls: parts ? 2 : 1,
391
+ rateLimit: bout.headers,
392
+ partial,
393
+ });
394
+ },
395
+ };
396
+ export const combatAthlete = {
397
+ name: 'combat_athlete',
398
+ description: `COMPOSITE RAF athlete / BKFC or Bellator fighter page: profile + recent bouts + career stats.
399
+
400
+ When to use:
401
+ - "Kyle Snyder's RAF record", "Arlovski BKFC stats", "Tito Ortiz in Bellator", athlete/fighter profile pages
402
+
403
+ athlete accepts a slug ("kyle-snyder") or a name ("Andrei Arlovski"); names are resolved through the league's
404
+ search, and several matches return AMBIGUOUS_ENTITY with candidates. Profile carries record, division, nickname,
405
+ country, official headshot (+ its credit — show it with the image). Career stats need the Starter plan; on Free
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.
411
+
412
+ Prefer over: call_api profile + history + stats separately.
413
+
414
+ Parallel-safe: yes. Upstream cost: 2–4.
415
+ Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
416
+ inputSchema: {
417
+ type: 'object',
418
+ additionalProperties: false,
419
+ required: ['league', 'athlete'],
420
+ properties: {
421
+ league: leagueSchema,
422
+ athlete: stringSchema('Athlete/fighter slug or name. Example: "kyle-snyder".'),
423
+ includeHistory: boolSchema('Include recent bouts (default true).', true),
424
+ historyLimit: limitSchema({ default: 10, max: 50, description: 'Max bouts of history (default 10, max 50).' }),
425
+ includeStats: boolSchema('Include career stats (default true; Starter plan).', true),
426
+ },
427
+ },
428
+ handler: async (args, ctx) => {
429
+ const d = begin('combat_athlete');
430
+ const league = parseLeague(args.league);
431
+ if (!league)
432
+ return leagueError(d, args.league);
433
+ const input = typeof args.athlete === 'string' ? args.athlete : typeof args.fighter === 'string' ? args.fighter : '';
434
+ if (!input.trim()) {
435
+ return errorEnvelope({ code: 'VALIDATION', message: 'athlete is required (slug or name)', game: league,
436
+ source: d.source, requestId: d.requestId, tookMs: Date.now() - d.started });
437
+ }
438
+ const r = await resolveOrFail(d, ctx, league, input, 'athlete');
439
+ if ('fail' in r)
440
+ return r.fail;
441
+ const base = `/${league}/${PEOPLE[league]}/${encodeURIComponent(r.slug)}`;
442
+ const historyLimit = clampInt(args.historyLimit, 10, 1, 50);
443
+ const [profile, history, stats] = await Promise.all([
444
+ fetchJson(ctx, base),
445
+ args.includeHistory === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/${HISTORY[league]}`, { query: { limit: historyLimit } }),
446
+ args.includeStats === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/stats`),
447
+ ]);
448
+ if (!profile.ok)
449
+ return httpError(d, league, profile, `${league} ${PEOPLE[league]} ${r.slug}`);
450
+ const partial = [];
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
+ }
456
+ if (history) {
457
+ if (history.ok)
458
+ data.history = extractRows(history.data).slice(0, historyLimit);
459
+ else
460
+ partial.push(sectionFailure('history', history, league));
461
+ }
462
+ if (stats) {
463
+ if (stats.ok)
464
+ data.stats = payload(stats);
465
+ else
466
+ partial.push(sectionFailure('stats', stats, league));
467
+ }
468
+ return successEnvelope({
469
+ data,
470
+ game: league,
471
+ source: d.source,
472
+ requestId: d.requestId,
473
+ tookMs: Date.now() - d.started,
474
+ upstreamCalls: 1 + (history ? 1 : 0) + (stats ? 1 : 0),
475
+ rateLimit: profile.headers,
476
+ partial,
477
+ });
478
+ },
479
+ };
480
+ export const combatH2h = {
481
+ name: 'combat_h2h',
482
+ description: `Head-to-head between two RAF athletes, two BKFC fighters or two Bellator fighters: record and every meeting.
483
+
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).
486
+
487
+ Parallel-safe: yes. Upstream cost: 1–3.
488
+ Example: { "league": "bkfc", "athleteA": "ryan-petersen", "athleteB": "gregoris-cisneros" }`,
489
+ inputSchema: {
490
+ type: 'object',
491
+ additionalProperties: false,
492
+ required: ['league', 'athleteA', 'athleteB'],
493
+ properties: {
494
+ league: leagueSchema,
495
+ athleteA: stringSchema('First athlete/fighter slug or name. Example: "kyle-snyder".'),
496
+ athleteB: stringSchema('Second athlete/fighter slug or name. Example: "wyatt-hendrickson".'),
497
+ },
498
+ },
499
+ handler: async (args, ctx) => {
500
+ const d = begin('combat_h2h');
501
+ const league = parseLeague(args.league);
502
+ if (!league)
503
+ return leagueError(d, args.league);
504
+ const a = typeof args.athleteA === 'string' ? args.athleteA : '';
505
+ const b = typeof args.athleteB === 'string' ? args.athleteB : '';
506
+ if (!a.trim() || !b.trim()) {
507
+ return errorEnvelope({ code: 'VALIDATION', message: 'athleteA and athleteB are required', game: league,
508
+ source: d.source, requestId: d.requestId, tookMs: Date.now() - d.started });
509
+ }
510
+ const [ra, rb] = await Promise.all([
511
+ resolveOrFail(d, ctx, league, a, 'athleteA'),
512
+ resolveOrFail(d, ctx, league, b, 'athleteB'),
513
+ ]);
514
+ if ('fail' in ra)
515
+ return ra.fail;
516
+ if ('fail' in rb)
517
+ return rb.fail;
518
+ const query = league === 'raf' ? { athlete1: ra.slug, athlete2: rb.slug } : { fighter1: ra.slug, fighter2: rb.slug };
519
+ const res = await fetchJson(ctx, `/${league}/h2h`, { query });
520
+ if (!res.ok)
521
+ return httpError(d, league, res, `${league} head-to-head`);
522
+ return successEnvelope({
523
+ data: { league, athleteA: ra.slug, athleteB: rb.slug, h2h: payload(res) },
524
+ game: league,
525
+ source: d.source,
526
+ requestId: d.requestId,
527
+ tookMs: Date.now() - d.started,
528
+ upstreamCalls: 1,
529
+ rateLimit: res.headers,
530
+ });
531
+ },
532
+ };
533
+ export const combatRankings = {
534
+ name: 'combat_rankings',
535
+ description: `Champions and rankings: RAF title holders per division, BKFC official rankings + champions.
536
+
537
+ BKFC: omit division for every division's ranked list (champion, interim champion, ranked fighters), or pass one
538
+ (e.g. "heavyweight", "womens-flyweight"). RAF has no ranked lists; it returns every title and its holder, or one
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.
542
+
543
+ Parallel-safe: yes. Upstream cost: 1.
544
+ Example: { "league": "bkfc", "division": "heavyweight" }`,
545
+ inputSchema: {
546
+ type: 'object',
547
+ additionalProperties: false,
548
+ required: ['league'],
549
+ properties: {
550
+ league: leagueSchema,
551
+ division: stringSchema('Optional division key. Example: "heavyweight".'),
552
+ },
553
+ },
554
+ handler: async (args, ctx) => {
555
+ const d = begin('combat_rankings');
556
+ const league = parseLeague(args.league);
557
+ if (!league)
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
+ }
566
+ const division = typeof args.division === 'string' ? args.division.trim().toLowerCase() : '';
567
+ const path = league === 'bkfc'
568
+ ? division ? `/bkfc/rankings/${encodeURIComponent(division)}` : '/bkfc/rankings'
569
+ : division ? `/raf/divisions/${encodeURIComponent(division)}` : '/raf/champions';
570
+ const res = await fetchJson(ctx, path);
571
+ if (!res.ok)
572
+ return httpError(d, league, res, `${league} rankings`);
573
+ return successEnvelope({
574
+ data: { league, division: division || null, rankings: payload(res) },
575
+ game: league,
576
+ source: d.source,
577
+ requestId: d.requestId,
578
+ tookMs: Date.now() - d.started,
579
+ upstreamCalls: 1,
580
+ rateLimit: res.headers,
581
+ });
582
+ },
583
+ };
584
+ 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) {
@@ -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 / Bellator MMA events: upcoming cards or recent results, with date, local time and venue (Bellator: archive, upcoming is empty with a note)',
10
+ parallelSafe: true,
11
+ games: ['raf', 'bkfc', 'bellator'],
12
+ jobs: ['schedule'],
13
+ exampleArgs: { league: 'bkfc', when: 'upcoming', limit: 5 },
14
+ preferOver: ['call_api /raf/events, /bkfc/events or /bellator/events'],
15
+ },
16
+ {
17
+ name: 'combat_card',
18
+ doNotUse: "One bout's period/round breakdown → combat_bout",
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
+ parallelSafe: true,
21
+ games: ['raf', 'bkfc', 'bellator'],
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) / Bellator per-round official stats in UFC format (Pro: the archive is older than the Free/Starter window)',
31
+ parallelSafe: true,
32
+ games: ['raf', 'bkfc', 'bellator'],
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 or Bellator fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name; Bellator fighters share the UFC slug (ufcFighterSlug)',
41
+ parallelSafe: true,
42
+ games: ['raf', 'bkfc', 'bellator'],
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, two BKFC fighters or two Bellator fighters',
51
+ parallelSafe: true,
52
+ games: ['raf', 'bkfc', 'bellator'],
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/Bellator never appear in UFC answers); Bellator has no rankings (NOT_IMPLEMENTED)',
59
+ preferOver: [],
60
+ outcome: 'RAF champions per division / BKFC official rankings and champions',
61
+ parallelSafe: true,
62
+ games: ['raf', 'bkfc', 'bellator'],
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'] },
@@ -484,7 +547,14 @@ const GAMES_META = [
484
547
  { id: 'cs2', label: 'Counter-Strike 2', depth: 'deep', notes: 'Prefixed ids; live path /cs2/live; trends/rankings' },
485
548
  { id: 'dota2', label: 'Dota 2', depth: 'thin', notes: 'Numeric ids; radar; no standings/roster endpoints' },
486
549
  { id: 'cod', label: 'Call of Duty (CDL)', depth: 'medium', notes: 'Org slugs; CDL standings; UUID match ids' },
487
- { 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
+ },
488
558
  ];
489
559
  export const listCapabilities = {
490
560
  name: 'list_capabilities',
@@ -724,6 +794,9 @@ const ALLOWLIST_PREFIXES = [
724
794
  '/ufc',
725
795
  '/fortnite',
726
796
  '/tennis',
797
+ '/raf',
798
+ '/bkfc',
799
+ '/bellator',
727
800
  '/openapi.json',
728
801
  ];
729
802
  function pathAllowed(path) {
@@ -747,7 +820,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
747
820
 
748
821
  Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
749
822
 
750
- Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis.
823
+ Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc, /bellator.
751
824
  Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
752
825
 
753
826
  Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
@@ -808,7 +881,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
808
881
  source: 'call_api',
809
882
  requestId,
810
883
  tookMs: Date.now() - started,
811
- 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',
812
885
  });
813
886
  }
814
887
  let query = {};
@@ -909,10 +982,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
909
982
  const started = Date.now();
910
983
  const requestId = newRequestId();
911
984
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
912
- if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
985
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator)$/)) {
913
986
  return errorEnvelope({
914
987
  code: 'UNSUPPORTED_GAME',
915
- message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis`,
988
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator`,
916
989
  game: null,
917
990
  source: 'list_routes',
918
991
  requestId,
@@ -935,7 +1008,7 @@ Example: { "game": "ufc", "q": "rankings" }`,
935
1008
  httpStatus: res.status,
936
1009
  recover: [
937
1010
  'Call list_capabilities for curated tools that need no route knowledge',
938
- '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',
939
1012
  ],
940
1013
  });
941
1014
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.7",
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).",
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
5
  "type": "module",
6
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.",
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
8
  "keywords": [
9
9
  "mcp",
10
10
  "model-context-protocol",
@@ -18,6 +18,7 @@
18
18
  "cod",
19
19
  "ufc",
20
20
  "mma",
21
+ "bellator",
21
22
  "tennis",
22
23
  "live-scores",
23
24
  "sports-data",