cito-mcp 0.2.1 → 0.2.2

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,7 +2,7 @@
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.2.1` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.2.2` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
7
  Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail REST stay available via `call_api`.
8
8
 
@@ -398,7 +398,7 @@ src/
398
398
 
399
399
  ## Publish notes
400
400
 
401
- Package: **`cito-mcp@0.2.1`**
401
+ Package: **`cito-mcp@0.2.2`**
402
402
 
403
403
  | Item | Value |
404
404
  | --- | --- |
package/dist/client.js CHANGED
@@ -137,19 +137,22 @@ export function extractRows(data) {
137
137
  if (typeof data !== 'object')
138
138
  return [];
139
139
  const obj = data;
140
+ // Order matters: UFC /ufc/live nests real bouts under liveBouts while also
141
+ // shipping supervisor `events` (no fighter names). Prefer bout-like keys first.
140
142
  for (const key of [
141
143
  'data',
144
+ 'liveBouts',
145
+ 'bouts',
142
146
  'matches',
143
147
  'items',
144
148
  'results',
145
149
  'teams',
146
150
  'players',
147
- 'events',
148
151
  'fighters',
149
152
  'tournaments',
150
153
  'leagues',
151
154
  'orgs',
152
- 'bouts',
155
+ 'events',
153
156
  'rankings',
154
157
  'standings',
155
158
  'rows',
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { errorEnvelope, toMcpResult } from './envelope.js';
18
18
  import { SERVER_INSTRUCTIONS } from './instructions.js';
19
19
  import { allTools, getTool } from './tools/index.js';
20
20
  import { runTool } from './tools/types.js';
21
- const PACKAGE_VERSION = '0.2.1';
21
+ const PACKAGE_VERSION = '0.2.2';
22
22
  const API_KEY = process.env.CITO_API_KEY;
23
23
  if (!API_KEY) {
24
24
  console.error('[cito-mcp] CITO_API_KEY is required.\n' +
@@ -12,6 +12,42 @@ const LIVE_PATHS = {
12
12
  cod: '/cod/matches/live',
13
13
  ufc: '/ufc/live',
14
14
  };
15
+ /**
16
+ * Extract live match/bout rows. UFC /ufc/live returns
17
+ * { liveBouts, events, ... } — events are supervisor shells without fighters.
18
+ * Prefer liveBouts (and nested tracking on events) over plain events[].
19
+ */
20
+ function extractLiveRows(data, game) {
21
+ const root = asRecord(data);
22
+ const payload = asRecord(root?.data) ?? root;
23
+ if (!payload)
24
+ return extractRows(data);
25
+ if (game === 'ufc') {
26
+ const liveBouts = Array.isArray(payload.liveBouts) ? payload.liveBouts : [];
27
+ if (liveBouts.length)
28
+ return liveBouts;
29
+ // Some shapes nest tracking under events[].tracking
30
+ const events = Array.isArray(payload.events) ? payload.events : [];
31
+ const fromTracking = [];
32
+ for (const ev of events) {
33
+ const er = asRecord(ev);
34
+ const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
35
+ for (const t of tracking) {
36
+ const tr = asRecord(t) ?? {};
37
+ fromTracking.push({
38
+ ...tr,
39
+ eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
40
+ eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
41
+ });
42
+ }
43
+ }
44
+ if (fromTracking.length)
45
+ return fromTracking;
46
+ // Last resort: empty live board rather than fake ? vs ? event shells
47
+ return [];
48
+ }
49
+ return extractRows(data);
50
+ }
15
51
  export const liveMatches = {
16
52
  name: 'live_matches',
17
53
  description: `Live matches board across primary games, or a single game filter. Normalized labels, scores, and matchIds.
@@ -88,12 +124,25 @@ Example: { "game": "all", "limitPerGame": 10 }`,
88
124
  });
89
125
  continue;
90
126
  }
91
- // Dota may nest matches at top-level and under data
92
- let rows = extractRows(res.data);
127
+ // Prefer real match/bout rows over supervisor event shells (UFC /ufc/live).
128
+ let rows = extractLiveRows(res.data, game);
93
129
  const obj = asRecord(res.data);
94
130
  if (rows.length === 0 && Array.isArray(obj?.matches))
95
131
  rows = obj.matches;
96
- const normalized = rows.slice(0, limitPerGame).map((row) => normalizeMatch(game, row, 'live'));
132
+ const normalized = rows
133
+ .slice(0, limitPerGame)
134
+ .map((row) => normalizeMatch(game, row, 'live'))
135
+ // Drop hollow UFC event-supervisor rows mistaken for bouts (no bout id + no sides).
136
+ .filter((m) => {
137
+ if (game !== 'ufc')
138
+ return true;
139
+ if (m.matchId && m.matchId !== 'unknown' && (m.team1?.name || m.team2?.name))
140
+ return true;
141
+ if (m.team1?.name && m.team2?.name)
142
+ return true;
143
+ // Keep if at least a real bout id even when fighters pending
144
+ return Boolean(m.matchId && m.matchId !== 'unknown' && !String(m.matchId).startsWith('event'));
145
+ });
97
146
  const items = labelsOnly
98
147
  ? normalized.map((m) => ({
99
148
  game: m.game,
@@ -265,19 +314,19 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
265
314
  }
266
315
  else if (game === 'ufc') {
267
316
  path = '/ufc/events/upcoming';
268
- query = { limit, page: Math.floor(offset / limit) + 1, includeBouts: true };
269
- if (args.hours !== undefined)
270
- noteIgnored('hours', 'UFC upcoming events list does not accept hours');
317
+ // Request more events than limit so client-side hours filter still has a card pool.
318
+ query = {
319
+ limit: Math.min(50, Math.max(limit * 3, limit)),
320
+ page: Math.floor(offset / limit) + 1,
321
+ includeBouts: true,
322
+ };
323
+ // hours / from / to applied client-side after expand (below) — do not warn as ignored.
271
324
  if (team)
272
325
  noteIgnored('team', 'UFC uses fighter filters via resolve/event_card, not team on schedule');
273
326
  if (league)
274
327
  noteIgnored('league', 'UFC has no league filter on upcoming events');
275
328
  if (tournamentId)
276
329
  noteIgnored('tournamentId', 'pass event slug via event_card instead');
277
- if (from)
278
- noteIgnored('from', 'UFC upcoming does not accept from');
279
- if (to)
280
- noteIgnored('to', 'UFC upcoming does not accept to');
281
330
  }
282
331
  const res = await fetchJson(ctx, path, { query });
283
332
  if (!res.ok) {
@@ -294,29 +343,69 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
294
343
  });
295
344
  }
296
345
  let rows = extractRows(res.data);
297
- // UFC events may need bout expansion
346
+ // UFC events expand to bout rows (fighters[] shape) so labels are not "? vs ?"
298
347
  if (game === 'ufc') {
299
348
  const expanded = [];
300
349
  for (const event of rows) {
301
350
  const er = asRecord(event) ?? {};
351
+ const eventName = pickString(er.name, er.title);
352
+ const eventId = pickString(er.id, er.slug);
353
+ const eventSlug = pickString(er.slug);
354
+ const eventStart = pickString(er.startTime, er.date, er.startsAt);
302
355
  const bouts = extractRows(er.bouts ?? er.fights);
303
356
  if (bouts.length) {
304
357
  for (const bout of bouts) {
305
358
  const br = asRecord(bout) ?? {};
306
359
  expanded.push({
307
360
  ...br,
308
- eventName: pickString(er.name, er.title),
309
- eventId: pickString(er.id, er.slug),
310
- eventSlug: pickString(er.slug),
311
- startTime: pickString(br.startTime, er.startTime, er.date),
361
+ eventName,
362
+ eventId,
363
+ eventSlug,
364
+ startTime: pickString(br.startTime, br.date, eventStart),
312
365
  });
313
366
  }
314
367
  }
315
368
  else {
316
- expanded.push(event);
369
+ // Keep event shell as a schedule card with a real label (not fighter matchup)
370
+ expanded.push({
371
+ id: eventId ?? eventSlug,
372
+ matchId: eventId ?? eventSlug,
373
+ name: eventName,
374
+ title: eventName,
375
+ label: eventName ?? eventSlug ?? 'UFC event',
376
+ startTime: eventStart,
377
+ eventName,
378
+ eventId,
379
+ eventSlug,
380
+ status: pickString(er.status) ?? 'upcoming',
381
+ });
317
382
  }
318
383
  }
319
384
  rows = expanded;
385
+ // Client-side hours / from / to window (API list has no hours param)
386
+ const now = Date.now();
387
+ const fromMs = from ? Date.parse(from) : now;
388
+ const toMs = to
389
+ ? Date.parse(to)
390
+ : args.hours !== undefined || !from
391
+ ? now + hours * 3600_000
392
+ : Number.POSITIVE_INFINITY;
393
+ if (Number.isFinite(fromMs) && Number.isFinite(toMs)) {
394
+ const before = rows.length;
395
+ rows = rows.filter((row) => {
396
+ const r = asRecord(row) ?? {};
397
+ const ts = pickString(r.startTime, r.date, r.startsAt, r.scheduledAt);
398
+ if (!ts)
399
+ return true; // keep undated rows rather than drop whole card
400
+ const t = Date.parse(ts);
401
+ if (!Number.isFinite(t))
402
+ return true;
403
+ return t >= fromMs && t <= toMs;
404
+ });
405
+ if (before > 0 && rows.length === 0) {
406
+ warnings.push(`hours/from/to window matched 0 of ${before} UFC rows — widen hours or omit time filters`);
407
+ }
408
+ }
320
409
  }
321
410
  // Client-side team filter when API ignored it
322
411
  if (team && (game === 'dota2' || game === 'cs2')) {
@@ -4,7 +4,7 @@
4
4
  import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
6
6
  import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
7
- const CATALOG_VERSION = '0.2.1';
7
+ const CATALOG_VERSION = '0.2.2';
8
8
  const TOOL_CATALOG = [
9
9
  {
10
10
  name: 'list_capabilities',
@@ -19,9 +19,10 @@ function nestedSide(raw) {
19
19
  return { name: raw };
20
20
  return null;
21
21
  }
22
- const name = pickString(o.name, o.code, o.shortName, o.nickname, o.displayName);
23
- const id = pickString(o.id, o.teamId, o.orgId);
24
- const slug = pickString(o.slug, o.orgSlug);
22
+ const profile = asRecord(o.profile);
23
+ const name = pickString(o.name, o.fighterName, o.code, o.shortName, o.nickname, o.displayName, profile?.name, profile?.nickname);
24
+ const id = pickString(o.id, o.teamId, o.orgId, o.fighterId, profile?.id);
25
+ const slug = pickString(o.slug, o.orgSlug, o.fighterSlug, profile?.slug);
25
26
  const scoreRaw = o.score ?? o.mapsWon ?? o.gamesWon;
26
27
  const score = typeof scoreRaw === 'number'
27
28
  ? scoreRaw
@@ -39,17 +40,56 @@ function scoreNum(v) {
39
40
  }
40
41
  export function normalizeMatch(game, row, forcedStatus) {
41
42
  const r = asRecord(row) ?? {};
42
- const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id) ?? 'unknown';
43
+ const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId) ?? 'unknown';
43
44
  let team1 = nestedSide(r.team1) ??
44
45
  sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
45
46
  let team2 = nestedSide(r.team2) ??
46
47
  sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
47
- // UFC fighters as corners
48
- if (game === 'ufc' && (!team1 || !team2)) {
49
- const f1 = nestedSide(r.redCorner ?? r.fighter1 ?? r.fighterA);
50
- const f2 = nestedSide(r.blueCorner ?? r.fighter2 ?? r.fighterB);
51
- team1 = team1 ?? f1;
52
- team2 = team2 ?? f2;
48
+ // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
49
+ // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
50
+ // Live tracking uses red/blue + fighters[] (not team1/team2).
51
+ if (game === 'ufc' || (!team1 && !team2 && (r.red || r.blue || Array.isArray(r.fighters)))) {
52
+ const fromCornerObj = (raw) => {
53
+ const o = asRecord(raw);
54
+ if (!o) {
55
+ if (typeof raw === 'string' && raw.trim())
56
+ return { name: raw.trim() };
57
+ return null;
58
+ }
59
+ // Nested profile from serializeBoutFighter
60
+ const profile = asRecord(o.profile);
61
+ const fighter = asRecord(o.fighter);
62
+ const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
63
+ const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
64
+ const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
65
+ const score = scoreNum(o.score ?? o.points);
66
+ return sideFrom(name, id, slug, score);
67
+ };
68
+ team1 =
69
+ team1 ??
70
+ fromCornerObj(r.red) ??
71
+ fromCornerObj(r.redCorner) ??
72
+ fromCornerObj(r.fighter1) ??
73
+ fromCornerObj(r.fighterA);
74
+ team2 =
75
+ team2 ??
76
+ fromCornerObj(r.blue) ??
77
+ fromCornerObj(r.blueCorner) ??
78
+ fromCornerObj(r.fighter2) ??
79
+ fromCornerObj(r.fighterB);
80
+ const fightersList = Array.isArray(r.fighters) ? r.fighters : [];
81
+ if (fightersList.length) {
82
+ const byCorner = (want) => fightersList.find((f) => {
83
+ const c = pickString(asRecord(f)?.corner, asRecord(f)?.side)?.toLowerCase();
84
+ return c === want || c === want[0]; // "red" / "r"
85
+ });
86
+ const redF = byCorner('red') ?? byCorner('r') ?? fightersList[0];
87
+ const blueF = byCorner('blue') ??
88
+ byCorner('b') ??
89
+ (fightersList.length > 1 ? fightersList[1] : undefined);
90
+ team1 = team1 ?? fromCornerObj(redF);
91
+ team2 = team2 ?? fromCornerObj(blueF);
92
+ }
53
93
  }
54
94
  // COD sometimes uses teams[]
55
95
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
@@ -8,11 +8,40 @@ function normalizeStandingRow(row, index) {
8
8
  const r = asRecord(row) ?? {};
9
9
  const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
10
10
  const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
11
+ // UFC official lists: champion has rank=null + rankText="C"; contenders 1..15.
12
+ // Never fall back to index+1 for null ranks — that produced two "#1" rows (champ + #1).
13
+ const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
14
+ const rankText = pickString(r.rankText);
15
+ const isChampion = championStatus === 'champion' ||
16
+ rankText === 'C' ||
17
+ r.isChampion === true ||
18
+ r.isTitleHolder === true;
19
+ let rank;
20
+ if (isChampion) {
21
+ rank = 'C';
22
+ }
23
+ else if (typeof r.rank === 'number') {
24
+ rank = r.rank;
25
+ }
26
+ else if (typeof r.position === 'number') {
27
+ rank = r.position;
28
+ }
29
+ else if (rankText && /^\d+$/.test(rankText)) {
30
+ rank = Number(rankText);
31
+ }
32
+ else if (r.rank === null && rankText) {
33
+ rank = rankText;
34
+ }
35
+ else {
36
+ rank = index + 1;
37
+ }
11
38
  return {
12
- rank: typeof r.rank === 'number' ? r.rank : typeof r.position === 'number' ? r.position : index + 1,
39
+ rank,
40
+ rankText: rankText ?? (typeof rank === 'number' || typeof rank === 'string' ? String(rank) : null),
41
+ championStatus: isChampion ? 'champion' : championStatus === 'interim' ? 'interim' : 'none',
13
42
  teamOrFighter: {
14
43
  id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
15
- slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug),
44
+ slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
16
45
  name,
17
46
  },
18
47
  played: r.played ?? r.gamesPlayed ?? r.matches ?? null,
@@ -24,7 +53,9 @@ function normalizeStandingRow(row, index) {
24
53
  streak: r.streak ?? null,
25
54
  meta: {
26
55
  ...(r.division ? { division: r.division } : {}),
56
+ ...(r.normalizedDivision ? { normalizedDivision: r.normalizedDivision } : {}),
27
57
  ...(r.region ? { region: r.region } : {}),
58
+ ...(isChampion ? { isChampion: true } : {}),
28
59
  },
29
60
  };
30
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {