cito-mcp 0.4.8 → 0.5.1

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 **non-UFC MMA archives** (Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, and `mma` across all of them) 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 the MMA archives are not on the `game` enum. `combat_events`, `combat_card`, `combat_bout`, `combat_athlete`, `combat_h2h` and `combat_rankings` take `league: "raf" | "bkfc" | "bellator" | "pride" | "strikeforce" | "wec" | "dream" | "elitexc" | "affliction" | "mma"`, and `call_api` reaches each of those prefixes. 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
+ | MMA archives: `bellator`, `pride`, `strikeforce`, `wec`, `dream`, `elitexc`, `affliction`, `mma` | per ROUND, official stats in UFC's shape (`"13 of 18"`, `controlTime "1:42"`, round 0 = whole fight; knockdowns, submission attempts, reversals) | Closed archives on one API router with identical routes: Bellator 131-172 (2014-2017), PRIDE 1997-2007 (PRIDE rules: 10-minute first round, then 5-minute rounds), Strikeforce 2006-2013, WEC 2001-2010, DREAM 2008-2012, EliteXC 2007-2008, Affliction 2008-2009. `mma` is every promotion at once: a fighter's whole non-UFC career, with `recordByPromotion` on `combat_athlete`, and `recent` / `last` across promotions; every bout and event carries its real `promotion`. Event slugs like `bellator-142`, `pride-34`, `wec-48`, `strikeforce-barnett-vs-cormier`; bout ids `<promotion>-<fightId>` (a bare fight id resolves in any archive). 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` · `/pride` · `/strikeforce` · `/wec` · `/dream` · `/elitexc` · `/affliction` · `/mma` · `/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
+ - **Non-UFC MMA archives**: `league` `bellator` (2014-2017), `pride` (1997-2007), `strikeforce` (2006-2013), `wec` (2001-2010), `dream` (2008-2012), `elitexc` (2007-2008), `affliction` (2008-2009) and `mma` (all of them at once) on every `combat_*` tool, and `/bellator` `/pride` `/strikeforce` `/wec` `/dream` `/elitexc` `/affliction` `/mma` on the `call_api` / `list_routes` allowlist. Official per-round stats in `/ufc`'s shape; fighters with a UFC record share the UFC slug (`combat_athlete` returns `ufcFighterSlug`, and under `mma` the whole non-UFC career split by `recordByPromotion`); `mma` events `recent` and `combat_card` `last` work across promotions. 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`): the MMA archives are 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, MMA archives /bellator /pride /strikeforce /wec /dream /elitexc /affliction /pfl /oktagon /mma',
42
42
  'Prefer a curated tool from list_capabilities when one covers the outcome',
43
43
  ],
44
44
  NOT_IMPLEMENTED: [
@@ -18,6 +18,10 @@ 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
+ - MMA archives: Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, plus /mma for a fighter's whole non-UFC career (recordByPromotion). combat_* tools with league bellator|pride|strikeforce|wec|dream|elitexc|affliction|mma, or call_api /{league}/.... Closed archives (1997-2017) 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). Fighters share their UFC slug (ufcFighterSlug). Never mix these into UFC answers. Their stats predate the Free/Starter window: HISTORY_WINDOW_EXCEEDED means Pro, not a bad key.
22
+ - PFL (Professional Fighters League): combat_* with league pfl, or call_api /pfl/.... Every card since 2018, upcoming cards (combat_events when upcoming, combat_card "next") and live fight status (/pfl/live: bout status "live" with live.round / live.clock). Results only (winner, method, round, time): no strike or round stats exist for PFL. league mma includes PFL. Never mix into UFC answers.
23
+ - OKTAGON MMA: combat_* with league oktagon, or call_api /oktagon/.... Every card since Oktagon 1 (2016), upcoming cards and live fight status. Per-ROUND stats on most bouts, LANDED ONLY: significant/total strikes, head/body/leg and takedowns as plain counts (landed.*), plus submission attempts; attempts, accuracy, knockdowns and control time are null because the source does not log them (each bout's statsCoverage says which fields exist). Fighters share their UFC slug when proven. league mma includes OKTAGON. Never mix into UFC answers.
24
+ - 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
25
 
22
26
  ## Preferred tool order
23
27
 
@@ -1,17 +1,46 @@
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 MMA_ARCHIVE_LEAGUES = ['bellator', 'pride', 'strikeforce', 'wec', 'dream', 'elitexc', 'affliction'];
5
+ export const MMA_LEAGUES = [...MMA_ARCHIVE_LEAGUES, 'pfl', 'oktagon', 'mma'];
6
+ export const COMBAT_LEAGUES = ['raf', 'bkfc', ...MMA_LEAGUES];
7
+ const isMma = (league) => MMA_LEAGUES.includes(league);
5
8
  const LEAGUE_NAMES = {
6
9
  raf: 'Real American Freestyle (pro freestyle wrestling)',
7
10
  bkfc: 'Bare Knuckle Fighting Championship',
11
+ bellator: 'Bellator MMA',
12
+ pride: 'PRIDE Fighting Championships',
13
+ strikeforce: 'Strikeforce',
14
+ wec: 'World Extreme Cagefighting',
15
+ dream: 'DREAM',
16
+ elitexc: 'EliteXC',
17
+ affliction: 'Affliction Entertainment',
18
+ pfl: 'Professional Fighters League',
19
+ oktagon: 'OKTAGON MMA',
20
+ mma: 'MMA beyond the UFC (every non-UFC promotion we hold, PFL and OKTAGON included)',
8
21
  };
9
- const PEOPLE = { raf: 'athletes', bkfc: 'fighters' };
10
- const HISTORY = { raf: 'bouts', bkfc: 'fights' };
22
+ const ARCHIVES = {
23
+ bellator: { short: 'Bellator', note: 'Bellator is a closed archive (2014-2024; official round stats for Bellator 131-172, results only after that)' },
24
+ pride: { short: 'PRIDE', note: 'PRIDE is a closed archive (1997-2007; PRIDE rules: a 10-minute first round, then 5-minute rounds)' },
25
+ strikeforce: { short: 'Strikeforce', note: 'Strikeforce is a closed archive (2006-2013)' },
26
+ wec: { short: 'WEC', note: 'WEC is a closed archive (2001-2010, until it merged into the UFC)' },
27
+ dream: { short: 'DREAM', note: 'DREAM is a closed archive (2008-2012)' },
28
+ elitexc: { short: 'EliteXC', note: 'EliteXC is a closed archive (2007-2008)' },
29
+ affliction: { short: 'Affliction', note: 'Affliction is a closed archive (2008-2009)' },
30
+ };
31
+ function archiveOf(league) {
32
+ return ARCHIVES[league];
33
+ }
34
+ const PEOPLE = (league) => (league === 'raf' ? 'athletes' : 'fighters');
35
+ const HISTORY = (league) => (league === 'raf' ? 'bouts' : 'fights');
11
36
  const leagueSchema = {
12
37
  type: 'string',
13
38
  enum: [...COMBAT_LEAGUES],
14
- description: 'League: "raf" (Real American Freestyle wrestling) or "bkfc" (Bare Knuckle Fighting Championship). Example: "bkfc".',
39
+ description: 'League: "raf" (Real American Freestyle wrestling), "bkfc" (Bare Knuckle Fighting Championship), or an MMA archive: ' +
40
+ '"bellator" (2014-2017), "pride" (1997-2007), "strikeforce" (2006-2013), "wec" (2001-2010), "dream" (2008-2012), ' +
41
+ '"elitexc" (2007-2008), "affliction" (2008-2009), "pfl" (PFL, 2018 to now: upcoming cards, live fight status, results only), ' +
42
+ '"oktagon" (OKTAGON MMA, 2016 to now: upcoming cards, live fight status, per-round LANDED strikes by target and takedowns), ' +
43
+ 'or "mma" (all of them at once). Example: "bkfc".',
15
44
  };
16
45
  function parseLeague(value) {
17
46
  const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
@@ -20,17 +49,25 @@ function parseLeague(value) {
20
49
  function leagueError(d, value) {
21
50
  return errorEnvelope({
22
51
  code: 'VALIDATION',
23
- message: `league must be raf or bkfc (got ${JSON.stringify(value ?? null)})`,
52
+ message: `league must be one of ${COMBAT_LEAGUES.join(', ')} (got ${JSON.stringify(value ?? null)})`,
24
53
  game: null,
25
54
  source: d.source,
26
55
  requestId: d.requestId,
27
56
  tookMs: Date.now() - d.started,
28
57
  });
29
58
  }
59
+ function isHistoryWindow(res) {
60
+ return res.status === 403 && asRecord(asRecord(res.data)?.error)?.code === 'HISTORY_WINDOW_EXCEEDED';
61
+ }
62
+ const HISTORY_WINDOW_RECOVER = [
63
+ 'Free reads 30 days and Starter 90 days of in-depth stats; Pro reads the full archive',
64
+ 'Summary sections (combat_card bouts, combat_athlete history) still return the records without stat blocks',
65
+ ];
30
66
  function httpError(d, league, res, what) {
31
67
  const body = asRecord(res.data);
32
68
  const err = asRecord(body?.error);
33
69
  return errorEnvelope({
70
+ ...(isHistoryWindow(res) ? { recover: HISTORY_WINDOW_RECOVER } : {}),
34
71
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
35
72
  message: (typeof err?.message === 'string' && err.message) || `${what} failed (HTTP ${res.status})`,
36
73
  game: league,
@@ -45,22 +82,47 @@ function payload(res) {
45
82
  const body = asRecord(res.data);
46
83
  return body && 'data' in body ? body.data : res.data;
47
84
  }
48
- function sectionFailure(section, res) {
85
+ function upstreamNotes(res) {
86
+ const meta = asRecord(asRecord(res.data)?.meta);
87
+ const out = {};
88
+ if (typeof meta?.note === 'string')
89
+ out.note = meta.note;
90
+ if (meta?.historyWindow)
91
+ out.historyWindow = meta.historyWindow;
92
+ return out;
93
+ }
94
+ function sectionFailure(section, res, league) {
49
95
  const body = asRecord(res.data);
50
96
  const err = asRecord(body?.error);
51
97
  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})`,
98
+ let message = err?.message || `${section} unavailable (HTTP ${res.status})`;
99
+ if (isHistoryWindow(res)) {
100
+ message =
101
+ `${section} is older than this key's history window (HISTORY_WINDOW_EXCEEDED: Free 30 days, Starter 90 days; ` +
102
+ `Pro reads the full archive)` + (league && archiveOf(league) ? `. ${archiveOf(league).note}, so all of its stats need Pro` : '');
103
+ }
104
+ else if (res.status === 403) {
105
+ message = `${section} needs the Starter plan or above for this league (${err?.message || 'plan restriction'})`;
106
+ }
107
+ return partialFromRejection(section, { code, httpStatus: res.status, message });
108
+ }
109
+ function archiveGap(d, league, message, recover, calls = 0) {
110
+ return errorEnvelope({
111
+ code: 'NOT_IMPLEMENTED',
112
+ message,
113
+ game: league,
114
+ source: d.source,
115
+ requestId: d.requestId,
116
+ tookMs: Date.now() - d.started,
117
+ upstreamCalls: calls,
118
+ retryable: false,
119
+ recover,
58
120
  });
59
121
  }
60
122
  async function resolvePerson(ctx, league, input) {
61
123
  const raw = input.trim();
62
124
  if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(raw)) {
63
- const probe = await fetchJson(ctx, `/${league}/${PEOPLE[league]}/${encodeURIComponent(raw)}`);
125
+ const probe = await fetchJson(ctx, `/${league}/${PEOPLE(league)}/${encodeURIComponent(raw)}`);
64
126
  if (probe.ok)
65
127
  return { slug: raw };
66
128
  if (probe.status !== 404)
@@ -91,7 +153,7 @@ async function resolveOrFail(d, ctx, league, input, argName) {
91
153
  return {
92
154
  fail: errorEnvelope({
93
155
  code: 'AMBIGUOUS_ENTITY',
94
- message: `"${input}" matches several ${PEOPLE[league]}; pass one slug from details.candidates`,
156
+ message: `"${input}" matches several ${PEOPLE(league)}; pass one slug from details.candidates`,
95
157
  game: league,
96
158
  source: d.source,
97
159
  requestId: d.requestId,
@@ -103,7 +165,7 @@ async function resolveOrFail(d, ctx, league, input, argName) {
103
165
  return {
104
166
  fail: errorEnvelope({
105
167
  code: 'NOT_FOUND',
106
- message: `No ${league.toUpperCase()} ${PEOPLE[league].slice(0, -1)} matches "${input}"`,
168
+ message: `No ${league.toUpperCase()} ${PEOPLE(league).slice(0, -1)} matches "${input}"`,
107
169
  game: league,
108
170
  source: d.source,
109
171
  requestId: d.requestId,
@@ -116,15 +178,18 @@ function begin(source) {
116
178
  }
117
179
  export const combatEvents = {
118
180
  name: 'combat_events',
119
- description: `RAF wrestling or BKFC bare-knuckle events: upcoming cards or recent results.
181
+ description: `RAF wrestling, BKFC bare-knuckle or MMA archive (Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction) events.
120
182
 
121
183
  When to use:
122
- - "When is the next RAF / BKFC card?", "recent BKFC results", schedule pages
184
+ - "When is the next RAF / BKFC card?", "recent BKFC results", "PRIDE results", "Bellator cards from 2015", schedule pages
123
185
  - Getting an event slug for combat_card
124
186
 
125
187
  Each event carries slug, name, date, local start time + timezone, venue/location, status and bout counts.
188
+ The MMA archives (bellator, pride, strikeforce, wec, dream, elitexc, affliction, and mma = all of them, each
189
+ event tagged with its real promotion) are closed: "when" defaults to "recent" (newest card first), and "upcoming"
190
+ returns an empty list with data.note instead of failing.
126
191
 
127
- Prefer over: call_api /raf/events or /bkfc/events.
192
+ Prefer over: call_api /{league}/events.
128
193
  Do not use when: you already have the event slug and want its bouts (combat_card).
129
194
 
130
195
  Parallel-safe: yes. Upstream cost: 1.
@@ -139,7 +204,8 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
139
204
  type: 'string',
140
205
  enum: ['upcoming', 'recent'],
141
206
  default: 'upcoming',
142
- description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Example: "recent".',
207
+ description: 'upcoming = scheduled cards, soonest first; recent = completed cards, newest first. Default "upcoming" ' +
208
+ '("recent" for the MMA archives). Example: "recent".',
143
209
  },
144
210
  limit: limitSchema({ default: 10, max: 50, description: 'Max events (default 10, max 50).' }),
145
211
  },
@@ -149,14 +215,28 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
149
215
  const league = parseLeague(args.league);
150
216
  if (!league)
151
217
  return leagueError(d, args.league);
152
- const when = args.when === 'recent' ? 'recent' : 'upcoming';
218
+ const archive = archiveOf(league);
219
+ const when = args.when === 'recent' || args.when === 'upcoming' ? args.when : archive ? 'recent' : 'upcoming';
153
220
  const limit = clampInt(args.limit, 10, 1, 50);
221
+ if (archive && when === 'upcoming') {
222
+ return successEnvelope({
223
+ data: {
224
+ league, leagueName: LEAGUE_NAMES[league], when, count: 0, events: [],
225
+ note: `${archive.note}: there are no upcoming cards. Use when "recent" for its cards, newest first.`,
226
+ },
227
+ game: league,
228
+ source: d.source,
229
+ requestId: d.requestId,
230
+ tookMs: Date.now() - d.started,
231
+ upstreamCalls: 0,
232
+ });
233
+ }
154
234
  const res = await fetchJson(ctx, `/${league}/events/${when}`, { query: { limit } });
155
235
  if (!res.ok)
156
236
  return httpError(d, league, res, `${league} ${when} events`);
157
237
  const events = extractRows(res.data).slice(0, limit);
158
238
  return successEnvelope({
159
- data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events },
239
+ data: { league, leagueName: LEAGUE_NAMES[league], when, count: events.length, events, ...upstreamNotes(res) },
160
240
  game: league,
161
241
  source: d.source,
162
242
  requestId: d.requestId,
@@ -168,15 +248,18 @@ Example: { "league": "bkfc", "when": "upcoming", "limit": 5 }`,
168
248
  };
169
249
  export const combatCard = {
170
250
  name: 'combat_card',
171
- description: `COMPOSITE fight card for one RAF or BKFC event: event identity + bouts in running order with results.
251
+ description: `COMPOSITE fight card for one RAF, BKFC or MMA archive event: event identity + bouts in running order with results.
172
252
 
173
253
  When to use:
174
- - "RAF 14 card", "BKFC 95 results", "who won the main event at the last BKFC?"
254
+ - "RAF 14 card", "BKFC 95 results", "Bellator 142 card", "PRIDE 34", "who won the main event at the last BKFC?"
175
255
  - Event pages and results pages
176
256
 
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).
257
+ event accepts a slug (e.g. "raf13", "bkfc-95-newark", "bellator-142", "pride-34", "wec-48") or "next" / "last".
258
+ Bouts come main event first; each carries weight class, title stakes, both sides (athletes for RAF, red/blue fighters for BKFC with
259
+ punch totals, red/blue fighters for the MMA archives with UFC-format totals and ufcFighterSlug), and once finished
260
+ the winner and result (RAF: score, takedowns, method; BKFC/MMA archives: method, round, time).
261
+ MMA archives are closed: event defaults to "last" (the most recent card; for mma, across every promotion), and
262
+ "next" is a non-retryable NOT_IMPLEMENTED (there are no upcoming cards).
180
263
 
181
264
  Prefer over: combat_events + call_api per bout.
182
265
  Do not use when: you need one bout's period/round breakdown (combat_bout).
@@ -189,7 +272,7 @@ Example: { "league": "raf", "event": "last" }`,
189
272
  required: ['league'],
190
273
  properties: {
191
274
  league: leagueSchema,
192
- event: stringSchema('Event slug, or "next" / "last" (default "next"). Example: "raf13".', 'next'),
275
+ event: stringSchema('Event slug, or "next" / "last" (default "next"; "last" for the MMA archives). Example: "raf13".', 'next'),
193
276
  limit: limitSchema({ default: 30, max: 40, description: 'Max bouts on the card (default 30, max 40).' }),
194
277
  },
195
278
  },
@@ -199,8 +282,14 @@ Example: { "league": "raf", "event": "last" }`,
199
282
  if (!league)
200
283
  return leagueError(d, args.league);
201
284
  const limit = clampInt(args.limit, 30, 1, 40);
202
- let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : 'next';
285
+ const archive = archiveOf(league);
286
+ let slug = typeof args.event === 'string' && args.event.trim() ? args.event.trim().toLowerCase() : archive ? 'last' : 'next';
203
287
  let calls = 0;
288
+ if (archive && slug === 'next') {
289
+ return archiveGap(d, league, `${archive.note}: there is no next card.`, [
290
+ `Use event "last" for the most recent ${league} card, or a slug from combat_events { league: "${league}", when: "recent" }`,
291
+ ]);
292
+ }
204
293
  if (slug === 'next' || slug === 'last') {
205
294
  const which = slug === 'next' ? 'upcoming' : 'recent';
206
295
  const list = await fetchJson(ctx, `/${league}/events/${which}`, { query: { limit: 1 } });
@@ -209,20 +298,24 @@ Example: { "league": "raf", "event": "last" }`,
209
298
  return httpError(d, league, list, `${league} ${which} events`);
210
299
  const first = asRecord(extractRows(list.data)[0]);
211
300
  if (!first?.slug) {
301
+ const windowed = !!upstreamNotes(list).historyWindow;
212
302
  return errorEnvelope({
213
303
  code: 'NOT_FOUND',
214
- message: `No ${which} ${league.toUpperCase()} event`,
304
+ message: windowed
305
+ ? `No ${which} ${league.toUpperCase()} event inside this key's history window (meta.historyWindow); pass an event slug instead`
306
+ : `No ${which} ${league.toUpperCase()} event`,
215
307
  game: league,
216
308
  source: d.source,
217
309
  requestId: d.requestId,
218
310
  tookMs: Date.now() - d.started,
219
311
  upstreamCalls: calls,
312
+ ...(windowed ? { retryable: false, details: { historyWindow: upstreamNotes(list).historyWindow } } : {}),
220
313
  });
221
314
  }
222
315
  slug = String(first.slug);
223
316
  }
224
317
  const [ev, bouts] = await Promise.all([
225
- fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`),
318
+ fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}`, archive ? { query: { includeBouts: 'false' } } : undefined),
226
319
  fetchJson(ctx, `/${league}/events/${encodeURIComponent(slug)}/bouts`),
227
320
  ]);
228
321
  calls += 2;
@@ -235,10 +328,10 @@ Example: { "league": "raf", "event": "last" }`,
235
328
  rows = Array.isArray(p) ? p : extractRows(asRecord(p)?.bouts ?? p);
236
329
  }
237
330
  else {
238
- partial.push(sectionFailure('bouts', bouts));
331
+ partial.push(sectionFailure('bouts', bouts, league));
239
332
  }
240
333
  const eventData = asRecord(payload(ev)) ?? {};
241
- const { bouts: _ignored, ...event } = eventData;
334
+ const { bouts: _ignored, card: _card, ...event } = eventData;
242
335
  return successEnvelope({
243
336
  data: { league, event, boutCount: rows.length, bouts: rows.slice(0, limit) },
244
337
  game: league,
@@ -256,13 +349,20 @@ Example: { "league": "raf", "event": "last" }`,
256
349
  };
257
350
  export const combatBout = {
258
351
  name: 'combat_bout',
259
- description: `One RAF or BKFC bout in full, with its scoring breakdown.
352
+ description: `One RAF, BKFC or MMA archive bout in full, with its scoring breakdown.
260
353
 
261
354
  RAF: winner, method (decision / tech fall / pin / injury), final score, takedowns, and per-PERIOD scores.
262
355
  BKFC: winner, method, round, time, and per-ROUND punch stats for red and blue (landed/thrown, head/body,
263
356
  distance/clinch, knockdowns). BKFC round stats need the Starter plan; on Free they come back as a partial note.
357
+ MMA archives (Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, mma): winner, method, round, time,
358
+ finish (position / submission), referee, judges' scores, each corner's whole-fight totals, and per-ROUND official
359
+ stats in UFC's format (significantStrikes "13 of 18", totalStrikes, takedowns, head/body/leg, distance/clinch/ground,
360
+ knockdowns, submissionAttempts, reversals, controlTime "1:42"). PRIDE's first round is 10 minutes. The archives are
361
+ older than the Free/Starter history window: on those plans rounds come back as a HISTORY_WINDOW_EXCEEDED partial
362
+ note while the result still returns.
264
363
 
265
- Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington").
364
+ Get boutId from combat_card or combat_athlete (e.g. "raf13--belal-muhammad--colby-covington", "bellator-5600").
365
+ MMA archive bout ids are "<promotion>-<fightId>"; a bare fight id resolves in any MMA archive league.
266
366
 
267
367
  Parallel-safe: yes. Upstream cost: 1–2.
268
368
  Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cisneros--ryan-petersen" }`,
@@ -272,8 +372,8 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
272
372
  required: ['league', 'boutId'],
273
373
  properties: {
274
374
  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),
375
+ boutId: stringSchema('Bout id from combat_card / combat_athlete (MMA archives: "<promotion>-<fightId>"). Example: "raf13--belal-muhammad--colby-covington".'),
376
+ includeBreakdown: boolSchema('Include per-period (RAF) / per-round (BKFC, MMA archives) breakdown (default true).', true),
277
377
  },
278
378
  },
279
379
  handler: async (args, ctx) => {
@@ -301,11 +401,13 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
301
401
  if (parts.ok)
302
402
  data[sub] = asRecord(payload(parts))?.[sub] ?? payload(parts);
303
403
  else
304
- partial.push(sectionFailure(sub, parts));
404
+ partial.push(sectionFailure(sub, parts, league));
305
405
  }
306
406
  const b = asRecord(data.bout);
307
407
  if (league === 'bkfc' && b && 'rounds' in b && data.rounds)
308
408
  delete b.rounds;
409
+ if (archiveOf(league) && b && 'roundStats' in b && (data.rounds || !breakdown))
410
+ delete b.roundStats;
309
411
  return successEnvelope({
310
412
  data,
311
413
  game: league,
@@ -320,15 +422,20 @@ Example: { "league": "bkfc", "boutId": "bkfc-fight-night-bethlehem--gregoris-cis
320
422
  };
321
423
  export const combatAthlete = {
322
424
  name: 'combat_athlete',
323
- description: `COMPOSITE RAF athlete / BKFC fighter page: profile + recent bouts + career stats.
425
+ description: `COMPOSITE RAF athlete / BKFC or MMA archive fighter page: profile + recent bouts + career stats.
324
426
 
325
427
  When to use:
326
- - "Kyle Snyder's RAF record", "Arlovski BKFC stats", athlete/fighter profile pages
428
+ - "Kyle Snyder's RAF record", "Arlovski BKFC stats", "Tito Ortiz in Bellator", "Fedor's PRIDE record", athlete/fighter profile pages
327
429
 
328
430
  athlete accepts a slug ("kyle-snyder") or a name ("Andrei Arlovski"); names are resolved through the league's
329
431
  search, and several matches return AMBIGUOUS_ENTITY with candidates. Profile carries record, division, nickname,
330
432
  country, official headshot (+ its credit — show it with the image). Career stats need the Starter plan; on Free
331
433
  they come back as a partial note while profile and history still return.
434
+ MMA archives: profile is bio (DOB, height, reach, stance, hometown) + the record in that promotion's archive (not the
435
+ whole career); stats are career totals in UFC's format. league "mma" covers every archived promotion at once, and
436
+ data.recordByPromotion splits the record ({ pride: {...}, strikeforce: {...} }). A fighter we also hold a UFC record
437
+ for keeps the SAME slug as /ufc/fighters/{slug}: data.ufcFighterSlug is set then (a UFC slug is accepted here too).
438
+ Keep the records apart: archive fights never belong in a UFC answer.
332
439
 
333
440
  Prefer over: call_api profile + history + stats separately.
334
441
 
@@ -359,28 +466,35 @@ Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
359
466
  const r = await resolveOrFail(d, ctx, league, input, 'athlete');
360
467
  if ('fail' in r)
361
468
  return r.fail;
362
- const base = `/${league}/${PEOPLE[league]}/${encodeURIComponent(r.slug)}`;
469
+ const base = `/${league}/${PEOPLE(league)}/${encodeURIComponent(r.slug)}`;
363
470
  const historyLimit = clampInt(args.historyLimit, 10, 1, 50);
364
471
  const [profile, history, stats] = await Promise.all([
365
472
  fetchJson(ctx, base),
366
- args.includeHistory === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/${HISTORY[league]}`, { query: { limit: historyLimit } }),
473
+ args.includeHistory === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/${HISTORY(league)}`, { query: { limit: historyLimit } }),
367
474
  args.includeStats === false ? Promise.resolve(null) : fetchJson(ctx, `${base}/stats`),
368
475
  ]);
369
476
  if (!profile.ok)
370
- return httpError(d, league, profile, `${league} ${PEOPLE[league]} ${r.slug}`);
477
+ return httpError(d, league, profile, `${league} ${PEOPLE(league)} ${r.slug}`);
371
478
  const partial = [];
372
479
  const data = { league, slug: r.slug, profile: payload(profile) };
480
+ if (isMma(league)) {
481
+ const ufc = asRecord(asRecord(data.profile)?.ufc);
482
+ data.ufcFighterSlug = typeof ufc?.ufcFighterSlug === 'string' ? ufc.ufcFighterSlug : null;
483
+ const byPromotion = asRecord(data.profile)?.recordByPromotion;
484
+ if (league === 'mma' || byPromotion)
485
+ data.recordByPromotion = asRecord(byPromotion) ?? null;
486
+ }
373
487
  if (history) {
374
488
  if (history.ok)
375
489
  data.history = extractRows(history.data).slice(0, historyLimit);
376
490
  else
377
- partial.push(sectionFailure('history', history));
491
+ partial.push(sectionFailure('history', history, league));
378
492
  }
379
493
  if (stats) {
380
494
  if (stats.ok)
381
495
  data.stats = payload(stats);
382
496
  else
383
- partial.push(sectionFailure('stats', stats));
497
+ partial.push(sectionFailure('stats', stats, league));
384
498
  }
385
499
  return successEnvelope({
386
500
  data,
@@ -396,9 +510,11 @@ Example: { "league": "raf", "athlete": "Kyle Snyder", "historyLimit": 5 }`,
396
510
  };
397
511
  export const combatH2h = {
398
512
  name: 'combat_h2h',
399
- description: `Head-to-head between two RAF athletes or two BKFC fighters: record and every meeting.
513
+ description: `Head-to-head between two RAF athletes, two BKFC fighters or two MMA archive fighters: record and every meeting.
400
514
 
401
- Accepts slugs or names for both sides (names resolve through the league's search).
515
+ Accepts slugs or names for both sides (names resolve through the league's search). MMA archive meetings are only
516
+ the ones on that promotion's cards (league "mma": every archived promotion); UFC meetings are a UFC question
517
+ (head_to_head).
402
518
 
403
519
  Parallel-safe: yes. Upstream cost: 1–3.
404
520
  Example: { "league": "bkfc", "athleteA": "ryan-petersen", "athleteB": "gregoris-cisneros" }`,
@@ -453,6 +569,9 @@ export const combatRankings = {
453
569
  BKFC: omit division for every division's ranked list (champion, interim champion, ranked fighters), or pass one
454
570
  (e.g. "heavyweight", "womens-flyweight"). RAF has no ranked lists; it returns every title and its holder, or one
455
571
  division's detail when division is given (e.g. "men-heavyweight").
572
+ The MMA archives (bellator, pride, strikeforce, wec, dream, elitexc, affliction, mma) have no rankings: they return a
573
+ non-retryable NOT_IMPLEMENTED.
574
+ Never answer with UFC rankings instead.
456
575
 
457
576
  Parallel-safe: yes. Upstream cost: 1.
458
577
  Example: { "league": "bkfc", "division": "heavyweight" }`,
@@ -470,6 +589,14 @@ Example: { "league": "bkfc", "division": "heavyweight" }`,
470
589
  const league = parseLeague(args.league);
471
590
  if (!league)
472
591
  return leagueError(d, args.league);
592
+ const archive = archiveOf(league) ?? (isMma(league) ? { short: league === 'pfl' ? 'PFL' : league === 'oktagon' ? 'OKTAGON' : 'The MMA namespace', note: 'the API carries its cards and results' } : undefined);
593
+ if (archive) {
594
+ return archiveGap(d, league, `${archive.short} has no rankings or champions lists: ${archive.note}, and the API serves its results only.`, [
595
+ `Do not retry: no ${league} rankings exist on the API`,
596
+ `Title fights: call_api /${league}/bouts with query { "title": "true" }; results: combat_events { league: "${league}", when: "recent" }`,
597
+ `Never substitute UFC rankings for ${archive.short}`,
598
+ ]);
599
+ }
473
600
  const division = typeof args.division === 'string' ? args.division.trim().toLowerCase() : '';
474
601
  const path = league === 'bkfc'
475
602
  ? division ? `/bkfc/rankings/${encodeURIComponent(division)}` : '/bkfc/rankings'
@@ -2,23 +2,24 @@ 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
+ import { COMBAT_LEAGUES } from './combat.js';
5
6
  const COMBAT_CATALOG = [
6
7
  {
7
8
  name: 'combat_events',
8
9
  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
+ outcome: 'RAF wrestling / BKFC bare-knuckle / MMA archive (Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, mma = all) events: upcoming cards or recent results, with date, local time and venue (archives: upcoming is empty with a note)',
10
11
  parallelSafe: true,
11
- games: ['raf', 'bkfc'],
12
+ games: [...COMBAT_LEAGUES],
12
13
  jobs: ['schedule'],
13
14
  exampleArgs: { league: 'bkfc', when: 'upcoming', limit: 5 },
14
- preferOver: ['call_api /raf/events or /bkfc/events'],
15
+ preferOver: ['call_api /{league}/events'],
15
16
  },
16
17
  {
17
18
  name: 'combat_card',
18
19
  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
+ outcome: 'One RAF/BKFC/MMA archive event card in running order with results; event = slug, "next" or "last" (archives: "last" = most recent card, across promotions for mma; no "next")',
20
21
  parallelSafe: true,
21
- games: ['raf', 'bkfc'],
22
+ games: [...COMBAT_LEAGUES],
22
23
  jobs: ['event_card', 'schedule', 'media'],
23
24
  exampleArgs: { league: 'raf', event: 'last' },
24
25
  preferOver: ['combat_events + call_api per bout'],
@@ -27,9 +28,9 @@ const COMBAT_CATALOG = [
27
28
  name: 'combat_bout',
28
29
  doNotUse: 'Whole card → combat_card',
29
30
  preferOver: [],
30
- outcome: 'One bout in full: RAF per-period scores / BKFC per-round punch stats (Starter)',
31
+ outcome: 'One bout in full: RAF per-period scores / BKFC per-round punch stats (Starter) / MMA archive per-round official stats in UFC format (Pro: the archives are older than the Free/Starter window)',
31
32
  parallelSafe: true,
32
- games: ['raf', 'bkfc'],
33
+ games: [...COMBAT_LEAGUES],
33
34
  jobs: ['match_page'],
34
35
  exampleArgs: { league: 'raf', boutId: 'raf13--belal-muhammad--colby-covington' },
35
36
  },
@@ -37,9 +38,9 @@ const COMBAT_CATALOG = [
37
38
  name: 'combat_athlete',
38
39
  doNotUse: "Two athletes' meetings → combat_h2h",
39
40
  preferOver: [],
40
- outcome: 'RAF athlete / BKFC fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name',
41
+ outcome: 'RAF athlete / BKFC or MMA archive fighter page: profile + headshot, recent bouts, career stats (Starter); slug or name; archive fighters share the UFC slug (ufcFighterSlug); league mma = whole non-UFC career with recordByPromotion',
41
42
  parallelSafe: true,
42
- games: ['raf', 'bkfc'],
43
+ games: [...COMBAT_LEAGUES],
43
44
  jobs: ['player_form', 'media'],
44
45
  exampleArgs: { league: 'raf', athlete: 'Kyle Snyder', historyLimit: 5 },
45
46
  },
@@ -47,19 +48,19 @@ const COMBAT_CATALOG = [
47
48
  name: 'combat_h2h',
48
49
  doNotUse: "One athlete's whole history → combat_athlete",
49
50
  preferOver: [],
50
- outcome: 'Head-to-head between two RAF athletes or two BKFC fighters',
51
+ outcome: 'Head-to-head between two RAF athletes, two BKFC fighters or two MMA archive fighters',
51
52
  parallelSafe: true,
52
- games: ['raf', 'bkfc'],
53
+ games: [...COMBAT_LEAGUES],
53
54
  jobs: ['h2h', 'preview'],
54
55
  exampleArgs: { league: 'bkfc', athleteA: 'ryan-petersen', athleteB: 'gregoris-cisneros' },
55
56
  },
56
57
  {
57
58
  name: 'combat_rankings',
58
- doNotUse: 'UFC rankings → standings / rankings tools (RAF/BKFC never appear in UFC answers)',
59
+ doNotUse: 'UFC rankings → standings / rankings tools (RAF/BKFC/MMA archives never appear in UFC answers); the MMA archives have no rankings (NOT_IMPLEMENTED)',
59
60
  preferOver: [],
60
61
  outcome: 'RAF champions per division / BKFC official rankings and champions',
61
62
  parallelSafe: true,
62
- games: ['raf', 'bkfc'],
63
+ games: [...COMBAT_LEAGUES],
63
64
  jobs: ['standings'],
64
65
  exampleArgs: { league: 'bkfc', division: 'heavyweight' },
65
66
  },
@@ -547,7 +548,14 @@ const GAMES_META = [
547
548
  { id: 'cs2', label: 'Counter-Strike 2', depth: 'deep', notes: 'Prefixed ids; live path /cs2/live; trends/rankings' },
548
549
  { id: 'dota2', label: 'Dota 2', depth: 'thin', notes: 'Numeric ids; radar; no standings/roster endpoints' },
549
550
  { 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' },
551
+ {
552
+ id: 'ufc',
553
+ label: 'UFC / MMA',
554
+ depth: 'medium',
555
+ notes: 'Fighter slugs; boutId; live + rankings. Includes Dana White\'s Contender Series and Road to UFC (2022-2026, all 19 cards ' +
556
+ 'with official round stats; event slugs like road-to-ufc-2-7, titles "Road to UFC 2.7"). Bellator, PRIDE, Strikeforce, WEC, ' +
557
+ 'DREAM, EliteXC and Affliction are NOT UFC: combat_* tools with that league (or mma), never mixed into UFC answers.',
558
+ },
551
559
  ];
552
560
  export const listCapabilities = {
553
561
  name: 'list_capabilities',
@@ -789,6 +797,16 @@ const ALLOWLIST_PREFIXES = [
789
797
  '/tennis',
790
798
  '/raf',
791
799
  '/bkfc',
800
+ '/bellator',
801
+ '/pride',
802
+ '/strikeforce',
803
+ '/wec',
804
+ '/dream',
805
+ '/elitexc',
806
+ '/affliction',
807
+ '/pfl',
808
+ '/oktagon',
809
+ '/mma',
792
810
  '/openapi.json',
793
811
  ];
794
812
  function pathAllowed(path) {
@@ -812,7 +830,7 @@ Prefer curated tools for all standard jobs (live, schedule, profiles, standings,
812
830
 
813
831
  Do not use when: a curated tool covers the outcome. Avoid parallel storms; same plan rate limits apply.
814
832
 
815
- Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc.
833
+ Path must start with / and match allowlisted prefixes: /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc, /bellator, /pride, /strikeforce, /wec, /dream, /elitexc, /affliction, /pfl, /oktagon, /mma.
816
834
  Rejects absolute URLs and path traversal → PATH_NOT_ALLOWED.
817
835
 
818
836
  Parallel-safe: yes but discouraged in bulk. Upstream cost: 1.
@@ -873,7 +891,7 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
873
891
  source: 'call_api',
874
892
  requestId,
875
893
  tookMs: Date.now() - started,
876
- hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis',
894
+ hint: 'Use prefixes /health /lol /cs2 /dota2 /cod /ufc /fortnite /tennis /raf /bkfc /bellator /pride /strikeforce /wec /dream /elitexc /affliction /pfl /oktagon /mma',
877
895
  });
878
896
  }
879
897
  let query = {};
@@ -974,10 +992,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
974
992
  const started = Date.now();
975
993
  const requestId = newRequestId();
976
994
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
977
- if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc)$/)) {
995
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator|pride|strikeforce|wec|dream|elitexc|affliction|pfl|oktagon|mma)$/)) {
978
996
  return errorEnvelope({
979
997
  code: 'UNSUPPORTED_GAME',
980
- message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc`,
998
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis|raf|bkfc|bellator|pride|strikeforce|wec|dream|elitexc|affliction|pfl|oktagon|mma`,
981
999
  game: null,
982
1000
  source: 'list_routes',
983
1001
  requestId,
@@ -1000,7 +1018,7 @@ Example: { "game": "ufc", "q": "rankings" }`,
1000
1018
  httpStatus: res.status,
1001
1019
  recover: [
1002
1020
  'Call list_capabilities for curated tools that need no route knowledge',
1003
- 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
1021
+ 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite, /tennis, /raf, /bkfc, /bellator, /pride, /strikeforce, /wec, /dream, /elitexc, /affliction, /pfl, /oktagon, /mma',
1004
1022
  ],
1005
1023
  });
1006
1024
  }
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.5.1",
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
+ }