cito-mcp 0.3.12 → 0.3.14

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.
@@ -47,10 +47,14 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
47
47
  recentForm = extractRows(unwrapPayload(fights.data) ?? fights.data)
48
48
  .slice(0, recentLimit)
49
49
  .map((row) => {
50
- // history rows nest bout
50
+ // History rows nest the bout and carry the fighter's own side as
51
+ // fighterSlug/opponent/outcome. Merge so normalizeMatch sees both,
52
+ // and do NOT force 'completed': the fighter's next scheduled bout is
53
+ // in this list too, and stamping it completed put an unfought fight
54
+ // at the top of the form with no result.
51
55
  const r = asRecord(row) ?? {};
52
- const bout = asRecord(r.bout) ?? r;
53
- return normalizeMatch('ufc', { ...bout, ...(r.fighterName ? {} : {}) }, 'completed');
56
+ const bout = asRecord(r.bout) ?? {};
57
+ return normalizeMatch('ufc', { ...bout, ...r });
54
58
  });
55
59
  }
56
60
  const stats = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/stats`);
@@ -201,11 +205,15 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
201
205
  }
202
206
  return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
203
207
  }
204
- function composeH2H(game, sideA, sideB, rows, limit) {
208
+ export function composeH2H(game, sideA, sideB, rows, limit) {
205
209
  const al = sideA.toLowerCase();
206
210
  const bl = sideB.toLowerCase();
207
211
  const meetings = rows
208
212
  .map((row) => normalizeMatch(game, row))
213
+ // A meeting is a fight that happened. The upcoming bout between the two
214
+ // sides sits in the same history list and must not count as a prior
215
+ // meeting, or every preview reports the fight it is previewing as H2H.
216
+ .filter((m) => m.status === 'completed')
209
217
  .filter((m) => {
210
218
  const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
211
219
  const s2 = [m.team2?.id, m.team2?.slug, m.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
@@ -219,7 +227,7 @@ function composeH2H(game, sideA, sideB, rows, limit) {
219
227
  return {
220
228
  meetings: meetings.length,
221
229
  lastMeetings: meetings.slice(0, 5),
222
- recordNote: meetings.length ? `${meetings.length} meetings found in window` : 'No H2H meetings in window',
230
+ recordNote: meetings.length ? `${meetings.length} past meetings found in window` : 'No past H2H meetings in window',
223
231
  };
224
232
  }
225
233
  export const matchPreview = {
@@ -406,8 +414,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
406
414
  rateLimit = { ...rateLimit, ...hist.headers };
407
415
  if (hist.ok) {
408
416
  rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
417
+ // Keep fighterSlug/opponent/outcome alongside the bout; they are
418
+ // the only place the two corners exist on a history row.
409
419
  const r = asRecord(row) ?? {};
410
- return asRecord(r.bout) ?? row;
420
+ return { ...(asRecord(r.bout) ?? {}), ...r };
411
421
  });
412
422
  }
413
423
  else {
@@ -428,6 +438,31 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
428
438
  }
429
439
  }
430
440
  // Factual briefing lines only — never structural meta like "roster loaded".
441
+ // Called with teamA + teamB and no matchId, context stayed empty
442
+ // ({ matchId:null, event:null, startTime:null }) for a headliner whose
443
+ // own form list had all three. Side A's history carries the booked bout
444
+ // against side B; that IS the context.
445
+ if (!matchId && context.matchId == null) {
446
+ const bl = String(teamB ?? '').toLowerCase();
447
+ const booked = sideARes.recentForm.find((m) => {
448
+ if (!m || (m.status !== 'upcoming' && m.status !== 'live'))
449
+ return false;
450
+ const sides = [m.team1?.slug, m.team1?.id, m.team1?.name, m.team2?.slug, m.team2?.id, m.team2?.name]
451
+ .filter(Boolean).map((x) => String(x).toLowerCase());
452
+ return sides.some((x) => x === bl || x.includes(bl) || bl.includes(x));
453
+ });
454
+ if (booked) {
455
+ context = {
456
+ ...context,
457
+ matchId: booked.matchId !== 'unknown' ? booked.matchId : null,
458
+ startTime: booked.startTime,
459
+ event: booked.event,
460
+ eventName: booked.event?.name ?? null,
461
+ status: booked.status,
462
+ label: booked.label,
463
+ };
464
+ }
465
+ }
431
466
  const talkingPoints = [];
432
467
  const sideAName = pickString(asRecord(sideARes.entity)?.name, teamA) ?? teamA;
433
468
  const sideBName = pickString(asRecord(sideBRes.entity)?.name, teamB) ?? teamB;
@@ -757,6 +792,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
757
792
  }));
758
793
  }
759
794
  }
795
+ // Bout rows carry no start time of their own; inherit the card's. Without
796
+ // this the same bout said startTime:null here and the event time on
797
+ // upcoming_schedule, and a caller had to fetch both to learn when it is.
798
+ const cardStart = typeof event.date === 'string' ? event.date : null;
799
+ bouts = bouts.map((b) => ({ ...b, startTime: b.startTime ?? cardStart }));
760
800
  if (includeStandings) {
761
801
  const ranks = await fetchJson(ctx, '/ufc/rankings');
762
802
  upstreamCalls += 1;
@@ -77,10 +77,16 @@ export function extractLiveRows(data, game) {
77
77
  export function inferUfcEmptyReason(events, health, liveCount) {
78
78
  if (liveCount > 0)
79
79
  return undefined;
80
+ const pulses = events.map((ev) => asRecord(ev) ?? {});
81
+ const hotNow = pulses.some((p) => ['live', 'warming', 'between_bouts', 'degraded'].includes(String(pickString(p.status, p.eventStatus) ?? '').toLowerCase()));
82
+ // The API's own health block is trusted first, EXCEPT when it blames the
83
+ // worker on a day nothing is live. It computes worker_stale from heartbeat
84
+ // lag alone, so an idle Thursday reads the same as a dead worker on fight
85
+ // night. With no hot card there is nothing to be stale about.
80
86
  const fromHealth = pickString(health?.emptyReason);
81
- if (fromHealth)
87
+ if (fromHealth && !(['worker_stale', 'worker_down_inferred'].includes(fromHealth) && !hotNow)) {
82
88
  return fromHealth;
83
- const pulses = events.map((ev) => asRecord(ev) ?? {});
89
+ }
84
90
  if (!pulses.length) {
85
91
  const alive = health?.workerAlive ?? health?.workerStarted;
86
92
  if (alive === false)
@@ -94,9 +100,13 @@ export function inferUfcEmptyReason(events, health, liveCount) {
94
100
  const freshest = lags.length ? Math.min(...lags) : null;
95
101
  const workerAlive = health?.workerAlive ?? health?.workerStarted;
96
102
  const hot = statuses.some((s) => ['live', 'warming', 'between_bouts', 'degraded'].includes(s));
97
- if (workerAlive === false && (hot || (freshest != null && freshest > 120))) {
103
+ // Only a HOT card can be stale. On an idle day every event is "scheduled"
104
+ // and the heartbeat lags because there is nothing to beat about; reporting
105
+ // that as worker_stale made a quiet Thursday indistinguishable from a dead
106
+ // worker on fight night. freshest stays computed for the health block.
107
+ void freshest;
108
+ if (workerAlive === false && hot)
98
109
  return 'worker_stale';
99
- }
100
110
  if (statuses.some((s) => s === 'degraded'))
101
111
  return 'event_degraded';
102
112
  if (statuses.some((s) => s === 'warming'))
@@ -126,8 +136,10 @@ export function humanUfcEmptyNote(emptyReason, health) {
126
136
  supervisor_warming: 'Card open / pre-gate; no bout live yet',
127
137
  between_bouts: 'Between bouts; next may be armed',
128
138
  armed_only: 'Next bout armed; not started',
129
- worker_stale: 'Worker heartbeat stale check pm2 ufc-live on VPS 169',
130
- worker_down_inferred: 'Worker appears down check pm2 ufc-live on VPS 169',
139
+ // Customer-facing. The pm2/VPS operator hint used to be in here and went
140
+ // out in every empty board; health{} already carries the numbers for us.
141
+ worker_stale: 'Live feed is behind; scores may lag',
142
+ worker_down_inferred: 'Live feed unavailable right now',
131
143
  event_degraded: 'Sources empty clock / event degraded; not inventing stats',
132
144
  };
133
145
  const msg = human[reason] || `No live|watching bouts (${reason})`;
@@ -284,9 +296,14 @@ Example: { "game": "all", "limitPerGame": 10 }`,
284
296
  if (game === 'ufc') {
285
297
  const health = asRecord(meta?.health) ?? asRecord(payload?.health);
286
298
  const events = Array.isArray(payload?.events) ? payload.events : [];
299
+ // One decision point. This used to short-circuit on the API's own
300
+ // emptyReason and only fall back to inferUfcEmptyReason when the API
301
+ // said nothing, so the idle-day rule inside it never ran: the API
302
+ // blames the worker on heartbeat lag alone and an empty Thursday came
303
+ // back as worker_stale. Hand the hint in and let the function weigh it.
304
+ const upstreamReason = pickString(meta?.emptyReason, health?.emptyReason);
287
305
  const emptyReason = normalized.length === 0
288
- ? pickString(meta?.emptyReason, health?.emptyReason) ??
289
- inferUfcEmptyReason(events, health, normalized.length)
306
+ ? inferUfcEmptyReason(events, { ...(health ?? {}), ...(upstreamReason ? { emptyReason: upstreamReason } : {}) }, normalized.length)
290
307
  : undefined;
291
308
  let note;
292
309
  if (normalized.length === 0) {
@@ -306,12 +323,37 @@ Example: { "game": "all", "limitPerGame": 10 }`,
306
323
  : undefined;
307
324
  const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
308
325
  const nextCard = normalized.length === 0 ? slimUfcNextCard(payload) : undefined;
326
+ // A one-word answer to "is anything wrong?". health{} is the worker's
327
+ // raw view and on an idle day it reads ok:false / workerAlive:false,
328
+ // which looks broken. state says what the board means: idle (nothing
329
+ // on, nothing due), live, stale (a hot card with a dead feed), or
330
+ // degraded. nextEvent tells an idle board what comes next, fetched
331
+ // only when the board is empty so a live night pays nothing for it.
332
+ const state = normalized.length > 0 ? 'live'
333
+ : emptyReason === 'worker_stale' || emptyReason === 'worker_down_inferred' ? 'stale'
334
+ : emptyReason === 'event_degraded' ? 'degraded'
335
+ : 'idle';
336
+ let nextEvent;
337
+ if (state === 'idle') {
338
+ const up = await fetchJson(ctx, '/ufc/events/upcoming', { query: { limit: 1 } });
339
+ upstreamCalls += 1;
340
+ const first = asRecord(extractRows(up.ok ? up.data : null)[0]);
341
+ if (first) {
342
+ nextEvent = {
343
+ slug: pickString(first.slug) ?? null,
344
+ name: pickString(first.title, first.name) ?? null,
345
+ startsAt: pickString(first.startsAt, first.date, first.startTime) ?? null,
346
+ };
347
+ }
348
+ }
309
349
  sections.push({
310
350
  game,
311
351
  count: normalized.length,
312
352
  ok: true,
353
+ state,
313
354
  note,
314
355
  emptyReason,
356
+ nextEvent,
315
357
  health: healthStrip,
316
358
  supervisor,
317
359
  nextCard,
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch } from './normalize.js';
6
+ import { normalizeMatch, normalizeUfcMethod } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
8
8
  async function getSection(ctx, path, query) {
9
9
  return fetchJson(ctx, path, { query });
@@ -78,7 +78,10 @@ function matchCore(game, matchId, raw) {
78
78
  team2: m.team2,
79
79
  event: m.event,
80
80
  league: m.league,
81
- method: pickString(r.method, r.resultMethod) ?? null,
81
+ // One enum on every surface. match_summary carried the source spelling
82
+ // here while the profile carried the enum, so consumers normalised anyway.
83
+ method: (game === 'ufc' ? normalizeUfcMethod(pickString(r.method, r.resultMethod)) : pickString(r.method, r.resultMethod)) ?? null,
84
+ methodRaw: pickString(r.method, r.resultMethod) ?? null,
82
85
  winner: (() => {
83
86
  // Never name a winner while play is in progress: the score fallback
84
87
  // below reads "who is ahead", which mid-match is a lead, not a result.
@@ -119,9 +119,17 @@ function sideExtras(o, game) {
119
119
  ...(rank ? { rank } : {}),
120
120
  // "none" carries no signal for a page; only surface an actual belt state.
121
121
  ...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
122
- ...(division ? { division } : {}),
123
- ...(country ? { country } : {}),
124
- ...(flag ? { flag } : {}),
122
+ // UFC corners always carry these keys. A debutant with no ranked division
123
+ // was shipping with the key ABSENT while every other corner had it, and a
124
+ // fighter from a country with no flag mapping did the same, which breaks
125
+ // strict parsers. Null says "not known"; absence says nothing.
126
+ ...(game === 'ufc'
127
+ ? { division: division ?? null, country: country ?? null, flag: flag ?? null }
128
+ : {
129
+ ...(division ? { division } : {}),
130
+ ...(country ? { country } : {}),
131
+ ...(flag ? { flag } : {}),
132
+ }),
125
133
  ...(outcome ? { outcome } : {}),
126
134
  };
127
135
  }
@@ -151,6 +159,40 @@ function scoreNum(v) {
151
159
  return Number(v);
152
160
  return null;
153
161
  }
162
+ /**
163
+ * Collapse the six spellings one outcome arrives in ("SUB" / "Submission",
164
+ * "U-DEC" / "Decision - Unanimous", "KO/TKO" / "TKO") onto one enum. The raw
165
+ * string is kept alongside as methodRaw; this is a stable key for consumers,
166
+ * not a rewrite of the source.
167
+ */
168
+ export function normalizeUfcMethod(raw) {
169
+ if (!raw)
170
+ return undefined;
171
+ const s = raw.trim().toLowerCase();
172
+ if (!s)
173
+ return undefined;
174
+ if (/overturn/.test(s))
175
+ return 'Overturned';
176
+ if (/no[\s-]?contest|^nc$/.test(s))
177
+ return 'NC';
178
+ if (/^dq$|disqualif/.test(s))
179
+ return 'DQ';
180
+ if (/draw/.test(s))
181
+ return 'Draw';
182
+ if (/sub/.test(s))
183
+ return 'SUB';
184
+ if (/ko|tko|knock|stoppage|strikes|punch|kick|elbow|knee/.test(s))
185
+ return 'KO/TKO';
186
+ if (/unanim|^u-?dec$/.test(s))
187
+ return 'U-DEC';
188
+ if (/split|^s-?dec$/.test(s))
189
+ return 'S-DEC';
190
+ if (/major|^m-?dec$/.test(s))
191
+ return 'M-DEC';
192
+ if (/dec/.test(s))
193
+ return 'DEC';
194
+ return raw.trim();
195
+ }
154
196
  export function normalizeMatch(game, row, forcedStatus) {
155
197
  // Always peel { success, data } so UFC bout fighters[] / status are visible.
156
198
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
@@ -216,7 +258,11 @@ export function normalizeMatch(game, row, forcedStatus) {
216
258
  const profile = asRecord(o.profile);
217
259
  const fighter = asRecord(o.fighter);
218
260
  const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
219
- const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
261
+ // o.id on a serialized corner is the ufc_bout_fighters ROW id, unique per
262
+ // bout, so the same human got a different "id" on every card. Prefer the
263
+ // fighter's own id wherever the API supplies it; fall back to the row id
264
+ // only when nothing better exists.
265
+ const id = pickString(o.fighterId, profile?.id, fighter?.id, o.id);
220
266
  const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
221
267
  const score = scoreNum(o.score ?? o.points);
222
268
  return sideFrom(name, id, slug, score, sideExtras(o, game));
@@ -247,6 +293,31 @@ export function normalizeMatch(game, row, forcedStatus) {
247
293
  team2 = team2 ?? fromCornerObj(blueF);
248
294
  }
249
295
  }
296
+ // UFC fighter-history rows (/ufc/fighters/{slug}/fights). These carry the
297
+ // fighter as fighterSlug/fighterName, the other corner under opponent, and
298
+ // the result as outcome, with NO fighters[] and no team1/team2. Every reader
299
+ // was left with two null sides: head_to_head filtered on those nulls and
300
+ // reported Jones vs Cormier as never having met, the profile showed
301
+ // team1:null with the division as the label, and the preview form could not
302
+ // say who won. One shape, three symptoms.
303
+ if (game === 'ufc' && !team1 && !team2 && typeof r.fighterSlug === 'string') {
304
+ const opp = asRecord(r.opponent);
305
+ const selfCorner = pickString(r.corner)?.toLowerCase();
306
+ const self = sideFrom(pickString(r.fighterName) ?? String(r.fighterSlug), undefined, String(r.fighterSlug), undefined, sideExtras(r, game));
307
+ const other = opp
308
+ ? sideFrom(pickString(opp.name) ?? pickString(opp.slug), undefined, pickString(opp.slug), undefined, sideExtras(opp, game))
309
+ : null;
310
+ // Keep red on team1 when we know the corner, so a card and a history row
311
+ // agree on which side is which.
312
+ if (selfCorner === 'blue' && other) {
313
+ team1 = other;
314
+ team2 = self;
315
+ }
316
+ else {
317
+ team1 = self;
318
+ team2 = other;
319
+ }
320
+ }
250
321
  // COD sometimes uses teams[]
251
322
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
252
323
  team1 = team1 ?? nestedSide(r.teams[0], game);
@@ -296,9 +367,20 @@ export function normalizeMatch(game, row, forcedStatus) {
296
367
  else if (hasResult)
297
368
  status = 'completed';
298
369
  }
370
+ // The winner, resolved once. Explicit winner fields first; otherwise a
371
+ // history row states the result from the fighter's own side (outcome: win /
372
+ // loss), which translates back into a slug. Both the score below and the
373
+ // result block use this, so a fight the profile shows as a win also counts
374
+ // as a win in head_to_head's tally, which reads the scores.
375
+ const historyOutcome = pickString(r.outcome)?.toLowerCase();
376
+ const historyOpponent = pickString(asRecord(r.opponent)?.slug);
377
+ const resolvedWinnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ??
378
+ (historyOutcome === 'win' && typeof r.fighterSlug === 'string' ? r.fighterSlug
379
+ : historyOutcome === 'loss' && historyOpponent ? historyOpponent
380
+ : undefined);
299
381
  // Winner → score 1-0 for UFC card display when numeric scores absent
300
382
  if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
301
- const winner = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner)?.toLowerCase();
383
+ const winner = resolvedWinnerSlug?.toLowerCase();
302
384
  if (winner) {
303
385
  const t1hit = [team1.slug, team1.id, team1.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
304
386
  const t2hit = [team2.slug, team2.id, team2.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
@@ -324,7 +406,14 @@ export function normalizeMatch(game, row, forcedStatus) {
324
406
  // When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
325
407
  // so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
326
408
  const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
327
- const explicit = pickString(r.label, r.title, r.name);
409
+ const explicitRaw = pickString(r.label, r.title, r.name);
410
+ // UFC history rows put the division where a title would go ("Heavyweight"),
411
+ // which then displaced "Jon Jones vs Stipe Miocic" as the label. A label that
412
+ // merely repeats the weight class is not a matchup; use the sides instead.
413
+ const explicit = game === 'ufc' && explicitRaw && (team1?.name || team2?.name) &&
414
+ explicitRaw.toLowerCase() === (pickString(r.weightClass, r.division) ?? '').toLowerCase()
415
+ ? undefined
416
+ : explicitRaw;
328
417
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
329
418
  const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
330
419
  let label;
@@ -348,12 +437,13 @@ export function normalizeMatch(game, row, forcedStatus) {
348
437
  const cardSectionOrder = numOrNull(r.cardSectionOrder);
349
438
  const boutOrder = numOrNull(r.boutOrder);
350
439
  const hasCard = cardSection != null || cardPosition != null || cardSectionOrder != null || boutOrder != null;
351
- const method = pickString(r.method);
440
+ const methodRaw = pickString(r.method);
441
+ const method = normalizeUfcMethod(methodRaw);
352
442
  const methodDetails = pickString(r.methodDetails);
353
443
  const resultTime = pickString(r.resultTime);
354
444
  // UFC sends referee as { id, name, firstName, lastName } — not a string.
355
445
  const referee = pickString(r.referee, asRecord(r.referee)?.name);
356
- const winnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
446
+ const winnerSlug = resolvedWinnerSlug;
357
447
  const resultRound = numOrNull(r.resultRound);
358
448
  const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
359
449
  const isCancelled = r.isCancelled === true;
@@ -383,6 +473,7 @@ export function normalizeMatch(game, row, forcedStatus) {
383
473
  ? {
384
474
  result: {
385
475
  method: method ?? null,
476
+ methodRaw: methodRaw ?? null,
386
477
  methodDetails: methodDetails ?? null,
387
478
  round: resultRound,
388
479
  time: resultTime ?? null,
@@ -457,7 +457,9 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
457
457
  if (res.ok) {
458
458
  const metrics = unwrapPayload(res.data);
459
459
  form = { summary: 'UFC fighter stats', trend: null, window: null, metrics };
460
- seasonStats = metrics;
460
+ // form.metrics and seasonStats were the same object twice, byte
461
+ // for byte. One copy. seasonStats stays for the games that have
462
+ // seasons; a fighter has a career, not a season.
461
463
  }
462
464
  else {
463
465
  partial.push(partialFromRejection('form', {
@@ -479,7 +481,11 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
479
481
  .slice(0, recentLimit)
480
482
  .map((row) => {
481
483
  const r = asRecord(row) ?? {};
482
- const bout = asRecord(r.bout) ?? row;
484
+ // Merge the row's own corner fields (fighterSlug / opponent /
485
+ // outcome) over the nested bout: they are the only place the two
486
+ // sides exist on a history row, and without them every entry
487
+ // came back team1:null, team2:null with the division as label.
488
+ const bout = { ...(asRecord(r.bout) ?? {}), ...r };
483
489
  // Never force 'completed'. A fighter's history endpoint also
484
490
  // returns bouts that are booked but not yet fought, and forcing
485
491
  // the status told agents that a future main event had already
@@ -488,11 +494,16 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
488
494
  // reported as completed with result null). Let normalizeMatch
489
495
  // derive it from the result and the start time instead.
490
496
  const m = normalizeMatch('ufc', bout);
497
+ // Keep the normalized result OBJECT (method, round, time,
498
+ // winnerSlug), the same shape match_preview and match_summary
499
+ // return. This used to overwrite it with the string "win",
500
+ // giving the profile a fourth result schema of its own. The
501
+ // fighter's-eye view lives on outcome instead.
491
502
  return {
492
503
  ...m,
493
- result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
504
+ outcome: pickString(r.outcome)?.toLowerCase() ?? null,
494
505
  opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
495
- method: pickString(asRecord(bout)?.method, r.method) ?? null,
506
+ method: m.result?.method ?? null,
496
507
  };
497
508
  });
498
509
  }
@@ -508,7 +519,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
508
519
  .slice(0, recentLimit)
509
520
  .map((row) => {
510
521
  const r = asRecord(row) ?? {};
511
- const bout = asRecord(r.bout) ?? row;
522
+ const bout = { ...(asRecord(r.bout) ?? {}), ...r };
512
523
  // Same as above: derive, never assert. See the note there.
513
524
  return normalizeMatch('ufc', bout);
514
525
  });
@@ -542,15 +553,25 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
542
553
  ...(player.slug ? { slug: player.slug } : {}),
543
554
  },
544
555
  },
545
- data: {
546
- player,
547
- currentTeam,
548
- recentMatches,
549
- form,
550
- career,
551
- radar,
552
- seasonStats,
553
- },
556
+ // Two rules, so a consumer can tell the difference. A field that can
557
+ // never apply to this game is OMITTED (a fighter has no team, no radar,
558
+ // no season). A field that applies but is unknown is NULL (division,
559
+ // flag). Sending team:null on every UFC profile forever said nothing.
560
+ data: game === 'ufc'
561
+ ? {
562
+ player: (({ team, role, nationality, ...rest }) => { void team; void role; void nationality; return rest; })(player),
563
+ recentMatches,
564
+ form,
565
+ }
566
+ : {
567
+ player,
568
+ currentTeam,
569
+ recentMatches,
570
+ form,
571
+ career,
572
+ radar,
573
+ seasonStats,
574
+ },
554
575
  });
555
576
  },
556
577
  };
@@ -21,7 +21,10 @@ export function normalizeStandingRow(row, index) {
21
21
  isInterim;
22
22
  let rank;
23
23
  if (isChampion) {
24
- rank = isInterim || rankText === 'IC' ? 'IC' : 'C';
24
+ // rank is numeric or null; the belt lives in rankText ("C"/"IC") and
25
+ // championStatus. Mixing a string into a numeric column forced every
26
+ // consumer to special-case it.
27
+ rank = null;
25
28
  }
26
29
  else if (typeof r.rank === 'number') {
27
30
  rank = r.rank;
@@ -122,6 +125,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
122
125
  stage: stringSchema('Stage key (COD / LoL).'),
123
126
  division: stringSchema('UFC division key.'),
124
127
  limit: limitSchema({ default: 50, max: 100 }),
128
+ cursor: stringSchema('Opaque cursor from pagination.nextCursor. UFC world scope spans every division; page with it rather than raising limit.'),
125
129
  },
126
130
  },
127
131
  handler: async (args, ctx) => {
@@ -140,6 +144,9 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
140
144
  }
141
145
  const game = gameParse.game;
142
146
  const limit = clampInt(args.limit, 50, 1, 100);
147
+ // Cursor is a plain offset. The whole table is in memory (UFC world scope
148
+ // is 176 rows), so nothing fancier is needed to walk every division.
149
+ const offset = Math.max(0, Number.parseInt(String(args.cursor ?? '0'), 10) || 0);
143
150
  const scope = typeof args.scope === 'string' ? args.scope : undefined;
144
151
  const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
145
152
  const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
@@ -314,7 +321,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
314
321
  rateLimit: res.headers,
315
322
  });
316
323
  }
317
- let rows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
324
+ // Keep the unsliced count. UFC world scope is every division back to back,
325
+ // so limit=50 stops a third of the way through flyweight; a consumer needs
326
+ // to know that happened rather than receive total:null and guess.
327
+ let allRows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i));
328
+ let rows = allRows.slice(offset, offset + limit);
318
329
  // UFC rankings may be nested by division
319
330
  if (game === 'ufc' && rows.length === 0) {
320
331
  const obj = asRecord(res.data) ?? {};
@@ -326,8 +337,30 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
326
337
  nested.push(...asRecord(value).rankings);
327
338
  }
328
339
  }
329
- rows = nested.map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
340
+ allRows = nested.map((row, i) => normalizeStandingRow(row, i));
341
+ rows = allRows.slice(offset, offset + limit);
342
+ }
343
+ if (game === 'ufc') {
344
+ rows = rows.map((row) => {
345
+ const { played, wins, losses, draws, points, mapDiff, streak, ...rest } = row;
346
+ void played;
347
+ void wins;
348
+ void losses;
349
+ void draws;
350
+ void points;
351
+ void mapDiff;
352
+ void streak;
353
+ return rest;
354
+ });
330
355
  }
356
+ const standingsPagination = {
357
+ limit,
358
+ offset,
359
+ total: allRows.length,
360
+ hasMore: offset + rows.length < allRows.length,
361
+ nextCursor: offset + rows.length < allRows.length ? String(offset + rows.length) : null,
362
+ prevCursor: offset > 0 ? String(Math.max(0, offset - limit)) : null,
363
+ };
331
364
  const obj = asRecord(res.data);
332
365
  const upstreamMeta = asRecord(obj?.meta) ?? {};
333
366
  const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
@@ -356,6 +389,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
356
389
  : 'Rankings dataFreshness=cached or stale.');
357
390
  }
358
391
  return successEnvelope({
392
+ pagination: standingsPagination,
359
393
  source: 'standings',
360
394
  game,
361
395
  requestId,
@@ -369,6 +403,8 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
369
403
  season: season ?? null,
370
404
  stage: stage ?? null,
371
405
  rows,
406
+ total: allRows.length,
407
+ hasMore: allRows.length > rows.length,
372
408
  updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
373
409
  null,
374
410
  ...(dataFreshness ? { dataFreshness } : {}),
@@ -829,8 +829,11 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
829
829
  });
830
830
  }
831
831
  const toBouts = (payload) => extractRows(payload).map((row) => {
832
+ // History rows carry the two corners as fighterSlug/opponent, not on
833
+ // the nested bout. Dropping them here is why Jones vs Cormier reported
834
+ // zero meetings while the profile listed both fights.
832
835
  const r = asRecord(row) ?? {};
833
- return asRecord(r.bout) ?? row;
836
+ return { ...(asRecord(r.bout) ?? {}), ...r };
834
837
  });
835
838
  const fromA = histA.ok ? toBouts(histA.data) : [];
836
839
  const fromB = histB.ok ? toBouts(histB.data) : [];
@@ -917,6 +920,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
917
920
  }
918
921
  const meetings = rows
919
922
  .map((row) => normalizeMatch(game, row))
923
+ // Past fights only. The booked bout between the two sides is in the
924
+ // same list and is not a meeting yet.
925
+ .filter((m) => m.status === 'completed')
920
926
  .filter((m) => sidesMatch(m, matchA, matchB))
921
927
  .filter((m) => {
922
928
  if (!fromIso && !toIso)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.12",
4
- "description": "Standalone MCP server for the Cito esports API 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
3
+ "version": "0.3.14",
4
+ "description": "Standalone MCP server for the Cito esports API \u2014 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cito-mcp": "dist/index.js"