cito-mcp 0.2.2 → 0.2.3

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.2` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.2.3` · **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
 
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.2';
21
+ const PACKAGE_VERSION = '0.2.3';
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' +
@@ -16,17 +16,39 @@ const LIVE_PATHS = {
16
16
  * Extract live match/bout rows. UFC /ufc/live returns
17
17
  * { liveBouts, events, ... } — events are supervisor shells without fighters.
18
18
  * Prefer liveBouts (and nested tracking on events) over plain events[].
19
+ * Exported for offline UFC projection tests.
19
20
  */
20
- function extractLiveRows(data, game) {
21
+ export function extractLiveRows(data, game) {
21
22
  const root = asRecord(data);
22
23
  const payload = asRecord(root?.data) ?? root;
23
24
  if (!payload)
24
25
  return extractRows(data);
25
26
  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
27
+ // Prefer liveBouts when the key is present (including empty [] — honest empty board).
28
+ if (Array.isArray(payload.liveBouts)) {
29
+ const liveBouts = payload.liveBouts;
30
+ if (liveBouts.length)
31
+ return liveBouts;
32
+ // Empty liveBouts: still check nested tracking, but never bare events[]
33
+ const eventsWithEmptyLive = Array.isArray(payload.events)
34
+ ? payload.events
35
+ : [];
36
+ const nested = [];
37
+ for (const ev of eventsWithEmptyLive) {
38
+ const er = asRecord(ev);
39
+ const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
40
+ for (const t of tracking) {
41
+ const tr = asRecord(t) ?? {};
42
+ nested.push({
43
+ ...tr,
44
+ eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
45
+ eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
46
+ });
47
+ }
48
+ }
49
+ return nested; // [] when only supervisor shells
50
+ }
51
+ // liveBouts key missing — try nested tracking under events
30
52
  const events = Array.isArray(payload.events) ? payload.events : [];
31
53
  const fromTracking = [];
32
54
  for (const ev of events) {
@@ -192,7 +214,8 @@ Filter support (unsupported params are ignored with meta.warnings — do not ass
192
214
  - lol: hours, team (slug), league (slug)
193
215
  - cs2: team, from, to (ISO); hours not applied upstream
194
216
  - cod: team, tournamentId
195
- - dota2 / ufc: limit/cursor primarily; team may be client-filtered where data allows
217
+ - dota2: limit/cursor primarily; team may be client-filtered where data allows
218
+ - ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
196
219
 
197
220
  Parallel-safe: yes. Upstream cost: 1–2.
198
221
  Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
@@ -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.2';
7
+ const CATALOG_VERSION = '0.2.3';
8
8
  const TOOL_CATALOG = [
9
9
  {
10
10
  name: 'list_capabilities',
@@ -115,8 +115,21 @@ export function normalizeMatch(game, row, forcedStatus) {
115
115
  const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
116
116
  const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
117
117
  const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
118
- const label = pickString(r.label, r.title, r.name) ??
119
- `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
118
+ // Prefer explicit title/label when present, but never keep the placeholder
119
+ // "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
120
+ const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
121
+ const explicit = pickString(r.label, r.title, r.name);
122
+ const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
123
+ let label;
124
+ if (explicit && !explicitIsPlaceholder) {
125
+ label = explicit;
126
+ }
127
+ else if (team1?.name || team2?.name) {
128
+ label = vsLabel;
129
+ }
130
+ else {
131
+ label = explicit ?? vsLabel;
132
+ }
120
133
  return {
121
134
  game,
122
135
  matchId,
@@ -136,6 +149,7 @@ export function entityRef(row, type, game) {
136
149
  'unknown';
137
150
  const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
138
151
  const name = pickString(r.name, r.nickname, r.displayName, r.tag, r.code, r.title, slug, id) ?? id;
152
+ const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
139
153
  return {
140
154
  game,
141
155
  type,
@@ -143,34 +157,61 @@ export function entityRef(row, type, game) {
143
157
  ...(slug ? { slug } : {}),
144
158
  name,
145
159
  meta: {
160
+ ...(nickname && nickname !== name ? { nickname } : {}),
146
161
  ...(r.region ? { region: r.region } : {}),
147
162
  ...(r.role ? { role: r.role } : {}),
148
163
  ...(r.nationality ? { nationality: r.nationality } : {}),
149
164
  ...(r.entity_type ? { entity_type: r.entity_type } : {}),
165
+ ...(r.division ? { division: r.division } : {}),
166
+ ...(r.p4pRank != null ? { p4pRank: r.p4pRank } : {}),
167
+ ...(r.isChampion != null ? { isChampion: r.isChampion } : {}),
150
168
  },
151
169
  };
152
170
  }
153
- /** Simple fuzzy score: exact > startsWith > includes (case-insensitive). */
154
- export function rankScore(query, name, id, slug) {
155
- const q = query.trim().toLowerCase();
171
+ /**
172
+ * Fuzzy score for entity resolution: exact > startsWith > includes > token overlap.
173
+ * Extra args (nickname, alt names) are scored as additional candidates.
174
+ * Multi-token person names ("jon jones") get a strong boost when every token hits.
175
+ */
176
+ export function rankScore(query, name, id, slug, ...extra) {
177
+ const q = query.trim().toLowerCase().replace(/\s+/g, ' ');
156
178
  if (!q)
157
179
  return 0;
158
- const candidates = [name, id, slug].filter(Boolean).map((s) => String(s).toLowerCase());
180
+ const candidates = [name, id, slug, ...extra]
181
+ .filter(Boolean)
182
+ .map((s) => String(s).toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim());
159
183
  let best = 0;
184
+ const qt = q.split(/\s+/).filter(Boolean);
160
185
  for (const c of candidates) {
186
+ if (!c)
187
+ continue;
161
188
  if (c === q)
162
189
  best = Math.max(best, 100);
163
190
  else if (c.startsWith(q))
164
- best = Math.max(best, 80 - Math.min(20, c.length - q.length));
191
+ best = Math.max(best, 85 - Math.min(15, c.length - q.length));
192
+ else if (q.startsWith(c) && c.length >= 3)
193
+ best = Math.max(best, 70);
165
194
  else if (c.includes(q))
166
- best = Math.max(best, 50 - Math.min(20, c.indexOf(q)));
195
+ best = Math.max(best, 55 - Math.min(20, c.indexOf(q)));
167
196
  else {
168
- // token overlap
169
- const qt = q.split(/\s+/);
170
- const ct = c.split(/[\s_-]+/);
171
- const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
172
- if (hits)
173
- best = Math.max(best, 20 + hits * 10);
197
+ const ct = c.split(/[\s]+/).filter(Boolean);
198
+ // all query tokens present as whole tokens (order-independent)
199
+ const allTokens = qt.length > 0 &&
200
+ qt.every((t) => ct.some((x) => x === t || x.startsWith(t) || t.startsWith(x)));
201
+ if (allTokens && qt.length >= 2) {
202
+ best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
203
+ }
204
+ else {
205
+ const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
206
+ if (hits === qt.length && qt.length > 0)
207
+ best = Math.max(best, 75);
208
+ else if (hits)
209
+ best = Math.max(best, 20 + hits * 12);
210
+ }
211
+ // last-name only: query last token equals candidate last token
212
+ if (qt.length >= 2 && ct.length >= 1 && ct[ct.length - 1] === qt[qt.length - 1]) {
213
+ best = Math.max(best, 60);
214
+ }
174
215
  }
175
216
  }
176
217
  return best;
@@ -8,31 +8,66 @@ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_
8
8
  function pushCandidates(out, game, type, rows, q, limit) {
9
9
  for (const row of rows) {
10
10
  const ref = entityRef(row, type, game);
11
- const score = rankScore(q, ref.name, ref.id, ref.slug);
12
- if (q && score <= 0 && type !== 'any') {
13
- // still include search API hits even if local rank is weak
14
- }
15
11
  const r = asRecord(row) ?? {};
12
+ const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
13
+ const score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
14
+ // Prefer positive fuzzy hits; keep weak API hits at floor 1 so real search results are not wiped.
15
+ if (q && score <= 0) {
16
+ // still allow through at floor so dedicated search endpoints aren't empty on odd nicknames
17
+ }
16
18
  const secondary = {};
17
19
  for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
18
20
  const v = pickString(r[key]);
19
21
  if (v)
20
22
  secondary[key] = v;
21
23
  }
24
+ if (nickname)
25
+ secondary.nickname = nickname;
22
26
  out.push({
23
27
  game,
24
28
  type: pickString(r.entity_type, r.type, type) ?? type,
25
29
  id: ref.id,
26
30
  slug: ref.slug,
27
31
  name: ref.name,
28
- score: q ? (score || 10) : 10,
32
+ score: q ? (score > 0 ? score : 1) : 10,
29
33
  ...(Object.keys(secondary).length ? { secondaryIds: secondary } : {}),
30
34
  });
31
35
  }
32
- out.sort((a, b) => b.score - a.score);
36
+ out.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
33
37
  if (out.length > limit * 3)
34
38
  out.length = limit * 3;
35
39
  }
40
+ /** UFC dedicated search (list /fighters ignores `q`). */
41
+ async function ufcSearch(ctx, q, type, limit) {
42
+ const candidates = [];
43
+ const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
44
+ if (!res.ok) {
45
+ return { candidates, calls: 1, ok: false, status: res.status };
46
+ }
47
+ const data = asRecord(res.data) ?? {};
48
+ // withMeta nests under data; extractRows may return fighters only — read buckets explicitly
49
+ const payload = asRecord(data.data) ?? data;
50
+ const fighters = Array.isArray(payload.fighters)
51
+ ? payload.fighters
52
+ : extractRows(payload.fighters);
53
+ const events = Array.isArray(payload.events)
54
+ ? payload.events
55
+ : extractRows(payload.events);
56
+ const bouts = Array.isArray(payload.bouts)
57
+ ? payload.bouts
58
+ : extractRows(payload.bouts);
59
+ if (type === 'any' || type === 'fighter' || type === 'player') {
60
+ pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
61
+ }
62
+ if (type === 'any' || type === 'event' || type === 'tournament') {
63
+ pushCandidates(candidates, 'ufc', 'event', events, q, limit);
64
+ }
65
+ if (type === 'any' || type === 'match') {
66
+ pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
67
+ }
68
+ candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
69
+ return { candidates: candidates.slice(0, limit), calls: 1, ok: true };
70
+ }
36
71
  async function searchGame(ctx, game, q, type, limit) {
37
72
  const candidates = [];
38
73
  let calls = 0;
@@ -164,24 +199,45 @@ async function searchGame(ctx, game, q, type, limit) {
164
199
  await Promise.all(tasks);
165
200
  }
166
201
  else if (game === 'ufc') {
167
- const tasks = [];
168
- if (type === 'any' || type === 'fighter' || type === 'player') {
169
- tasks.push((async () => {
170
- const res = await fetchJson(ctx, '/ufc/fighters', { query: { q, limit, page: 1 } });
171
- calls += 1;
172
- if (res.ok)
173
- pushCandidates(candidates, game, 'fighter', extractRows(res.data), q, limit);
174
- })());
202
+ // /ufc/fighters ignores q (returns p4p/champ list). Always use /ufc/search when querying.
203
+ if (q) {
204
+ const ufc = await ufcSearch(ctx, q, type, limit);
205
+ calls += ufc.calls;
206
+ if (ufc.ok) {
207
+ candidates.push(...ufc.candidates);
208
+ }
209
+ else {
210
+ return {
211
+ candidates,
212
+ calls,
213
+ error: partialFromRejection(`game:${game}`, {
214
+ code: mapHttpToCode(ufc.status ?? 0),
215
+ message: `ufc search HTTP ${ufc.status ?? 0}`,
216
+ httpStatus: ufc.status,
217
+ }),
218
+ };
219
+ }
175
220
  }
176
- if (type === 'any' || type === 'event' || type === 'tournament') {
177
- tasks.push((async () => {
178
- const res = await fetchJson(ctx, '/ufc/events', { query: { q, limit, page: 1 } });
179
- calls += 1;
180
- if (res.ok)
181
- pushCandidates(candidates, game, 'event', extractRows(res.data), q, limit);
182
- })());
221
+ else {
222
+ const tasks = [];
223
+ if (type === 'any' || type === 'fighter' || type === 'player') {
224
+ tasks.push((async () => {
225
+ const res = await fetchJson(ctx, '/ufc/fighters', { query: { limit, page: 1 } });
226
+ calls += 1;
227
+ if (res.ok)
228
+ pushCandidates(candidates, game, 'fighter', extractRows(res.data), q, limit);
229
+ })());
230
+ }
231
+ if (type === 'any' || type === 'event' || type === 'tournament') {
232
+ tasks.push((async () => {
233
+ const res = await fetchJson(ctx, '/ufc/events', { query: { limit, page: 1 } });
234
+ calls += 1;
235
+ if (res.ok)
236
+ pushCandidates(candidates, game, 'event', extractRows(res.data), q, limit);
237
+ })());
238
+ }
239
+ await Promise.all(tasks);
183
240
  }
184
- await Promise.all(tasks);
185
241
  }
186
242
  }
187
243
  catch (e) {
@@ -312,14 +368,17 @@ When to use:
312
368
  - Typeahead / pickers
313
369
  - "List teams matching…"
314
370
  - Exploring entities without committing to one ID
371
+ - UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
315
372
 
316
373
  Prefer over resolve_entity when the user wants a list.
317
374
  Prefer resolve_entity when chaining one name into a profile tool.
318
375
 
319
376
  Do not use when: fetching a known entity profile — use team_profile or player_profile.
320
377
 
378
+ UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
379
+
321
380
  Parallel-safe: yes. Upstream cost: 1–3.
322
- Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
381
+ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
323
382
  inputSchema: {
324
383
  type: 'object',
325
384
  additionalProperties: false,
@@ -466,11 +525,69 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
466
525
  }
467
526
  }
468
527
  else if (game === 'ufc') {
469
- if (type === 'fighter' || type === 'player' || type === 'any') {
470
- await listPath('/ufc/fighters', 'fighter', q ? { q } : {});
528
+ if (q) {
529
+ // Dedicated search ranks name/slug/nickname; /fighters ignores q.
530
+ const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
531
+ upstreamCalls += 1;
532
+ if (!res.ok) {
533
+ throw Object.assign(new Error(`HTTP ${res.status}`), {
534
+ status: res.status,
535
+ data: res.data,
536
+ headers: res.headers,
537
+ });
538
+ }
539
+ const data = asRecord(res.data) ?? {};
540
+ const payload = asRecord(data.data) ?? data;
541
+ const scored = [];
542
+ if (type === 'fighter' || type === 'player' || type === 'any') {
543
+ const fighters = Array.isArray(payload.fighters)
544
+ ? payload.fighters
545
+ : extractRows(payload.fighters);
546
+ for (const row of fighters) {
547
+ const ref = entityRef(row, 'fighter', game);
548
+ const r = asRecord(row) ?? {};
549
+ const nick = pickString(r.nickname);
550
+ const score = rankScore(q, ref.name, ref.id, ref.slug, nick);
551
+ if (score > 0)
552
+ scored.push({ ref, score });
553
+ }
554
+ }
555
+ if (type === 'event' || type === 'tournament' || type === 'any') {
556
+ const events = Array.isArray(payload.events)
557
+ ? payload.events
558
+ : extractRows(payload.events);
559
+ for (const row of events) {
560
+ const ref = entityRef(row, 'event', game);
561
+ const score = rankScore(q, ref.name, ref.id, ref.slug);
562
+ if (score > 0)
563
+ scored.push({ ref, score });
564
+ }
565
+ }
566
+ if (type === 'match' || type === 'any') {
567
+ const bouts = Array.isArray(payload.bouts)
568
+ ? payload.bouts
569
+ : extractRows(payload.bouts);
570
+ for (const row of bouts) {
571
+ const ref = entityRef(row, 'match', game);
572
+ const score = rankScore(q, ref.name, ref.id, ref.slug);
573
+ if (score > 0)
574
+ scored.push({ ref, score });
575
+ }
576
+ }
577
+ scored.sort((a, b) => b.score - a.score || a.ref.name.localeCompare(b.ref.name));
578
+ items = scored.map((s) => ({
579
+ ...s.ref,
580
+ meta: { ...s.ref.meta, score: s.score },
581
+ }));
582
+ total = items.length;
471
583
  }
472
- if (type === 'event' || type === 'tournament' || type === 'any') {
473
- await listPath('/ufc/events', 'event', q ? { q } : {});
584
+ else {
585
+ if (type === 'fighter' || type === 'player' || type === 'any') {
586
+ await listPath('/ufc/fighters', 'fighter');
587
+ }
588
+ if (type === 'event' || type === 'tournament' || type === 'any') {
589
+ await listPath('/ufc/events', 'event');
590
+ }
474
591
  }
475
592
  }
476
593
  }
@@ -487,7 +604,7 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
487
604
  rateLimit: e.headers,
488
605
  });
489
606
  }
490
- // de-dupe by game+type+id
607
+ // de-dupe by game+type+id; when q present re-sort by score if available
491
608
  const seen = new Set();
492
609
  items = items.filter((it) => {
493
610
  const key = `${it.game}:${it.type}:${it.id}`;
@@ -495,8 +612,17 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
495
612
  return false;
496
613
  seen.add(key);
497
614
  return true;
498
- }).slice(0, limit);
499
- const hasMore = items.length >= limit;
615
+ });
616
+ if (q) {
617
+ items.sort((a, b) => {
618
+ const sa = typeof a.meta?.score === 'number' ? a.meta.score : rankScore(q, a.name, a.id, a.slug, a.meta?.nickname);
619
+ const sb = typeof b.meta?.score === 'number' ? b.meta.score : rankScore(q, b.name, b.id, b.slug, b.meta?.nickname);
620
+ return sb - sa || a.name.localeCompare(b.name);
621
+ });
622
+ }
623
+ // pagination over ranked result set
624
+ const pageItems = items.slice(offset, offset + limit);
625
+ const hasMore = offset + pageItems.length < items.length || (!q && items.length >= limit);
500
626
  return successEnvelope({
501
627
  source: 'search_entities',
502
628
  game,
@@ -506,12 +632,12 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
506
632
  pagination: {
507
633
  limit,
508
634
  offset,
509
- total,
635
+ total: total ?? (q ? items.length : null),
510
636
  hasMore,
511
637
  nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
512
638
  prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
513
639
  },
514
- data: { items },
640
+ data: { items: pageItems },
515
641
  });
516
642
  },
517
643
  };
@@ -4,21 +4,24 @@
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
6
6
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
7
- function normalizeStandingRow(row, index) {
7
+ /** Exported for offline UFC rank mapping tests. */
8
+ export function normalizeStandingRow(row, index) {
8
9
  const r = asRecord(row) ?? {};
9
10
  const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
10
11
  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).
12
+ // UFC official lists: champion has rank=null + rankText="C" (interim "IC"); contenders 1..15.
13
+ // Never fall back to index+1 for explicit null ranks — that produced two "#1" rows (champ + #1).
13
14
  const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
14
15
  const rankText = pickString(r.rankText);
16
+ const isInterim = championStatus === 'interim' || rankText === 'IC' || r.isInterimChampion === true;
15
17
  const isChampion = championStatus === 'champion' ||
16
18
  rankText === 'C' ||
17
19
  r.isChampion === true ||
18
- r.isTitleHolder === true;
20
+ r.isTitleHolder === true ||
21
+ isInterim;
19
22
  let rank;
20
23
  if (isChampion) {
21
- rank = 'C';
24
+ rank = isInterim || rankText === 'IC' ? 'IC' : 'C';
22
25
  }
23
26
  else if (typeof r.rank === 'number') {
24
27
  rank = r.rank;
@@ -29,16 +32,35 @@ function normalizeStandingRow(row, index) {
29
32
  else if (rankText && /^\d+$/.test(rankText)) {
30
33
  rank = Number(rankText);
31
34
  }
32
- else if (r.rank === null && rankText) {
35
+ else if (r.rank === null) {
36
+ // Explicit null (UFC champ without flags, or unranked) — never invent index+1
37
+ rank = rankText ?? null;
38
+ }
39
+ else if (rankText) {
33
40
  rank = rankText;
34
41
  }
42
+ else if (r.rank === undefined && r.position === undefined) {
43
+ rank = index + 1; // last resort only when rank fields are absent entirely
44
+ }
35
45
  else {
36
- rank = index + 1;
46
+ rank = null;
37
47
  }
48
+ const resolvedChampionStatus = isInterim
49
+ ? 'interim'
50
+ : isChampion
51
+ ? 'champion'
52
+ : championStatus === 'interim'
53
+ ? 'interim'
54
+ : 'none';
38
55
  return {
39
56
  rank,
40
- rankText: rankText ?? (typeof rank === 'number' || typeof rank === 'string' ? String(rank) : null),
41
- championStatus: isChampion ? 'champion' : championStatus === 'interim' ? 'interim' : 'none',
57
+ rankText: rankText ??
58
+ (rank === 'C' || rank === 'IC'
59
+ ? String(rank)
60
+ : typeof rank === 'number' || typeof rank === 'string'
61
+ ? String(rank)
62
+ : null),
63
+ championStatus: resolvedChampionStatus,
42
64
  teamOrFighter: {
43
65
  id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
44
66
  slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
@@ -56,6 +78,7 @@ function normalizeStandingRow(row, index) {
56
78
  ...(r.normalizedDivision ? { normalizedDivision: r.normalizedDivision } : {}),
57
79
  ...(r.region ? { region: r.region } : {}),
58
80
  ...(isChampion ? { isChampion: true } : {}),
81
+ ...(isInterim ? { isInterim: true } : {}),
59
82
  },
60
83
  };
61
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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": {