cito-mcp 0.4.7 → 0.4.8

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