cito-mcp 0.3.12 → 0.3.13

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,7 +205,7 @@ 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
@@ -406,8 +410,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
406
410
  rateLimit = { ...rateLimit, ...hist.headers };
407
411
  if (hist.ok) {
408
412
  rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
413
+ // Keep fighterSlug/opponent/outcome alongside the bout; they are
414
+ // the only place the two corners exist on a history row.
409
415
  const r = asRecord(row) ?? {};
410
- return asRecord(r.bout) ?? row;
416
+ return { ...(asRecord(r.bout) ?? {}), ...r };
411
417
  });
412
418
  }
413
419
  else {
@@ -757,6 +763,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
757
763
  }));
758
764
  }
759
765
  }
766
+ // Bout rows carry no start time of their own; inherit the card's. Without
767
+ // this the same bout said startTime:null here and the event time on
768
+ // upcoming_schedule, and a caller had to fetch both to learn when it is.
769
+ const cardStart = typeof event.date === 'string' ? event.date : null;
770
+ bouts = bouts.map((b) => ({ ...b, startTime: b.startTime ?? cardStart }));
760
771
  if (includeStandings) {
761
772
  const ranks = await fetchJson(ctx, '/ufc/rankings');
762
773
  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) {
@@ -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,
@@ -479,7 +479,11 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
479
479
  .slice(0, recentLimit)
480
480
  .map((row) => {
481
481
  const r = asRecord(row) ?? {};
482
- const bout = asRecord(r.bout) ?? row;
482
+ // Merge the row's own corner fields (fighterSlug / opponent /
483
+ // outcome) over the nested bout: they are the only place the two
484
+ // sides exist on a history row, and without them every entry
485
+ // came back team1:null, team2:null with the division as label.
486
+ const bout = { ...(asRecord(r.bout) ?? {}), ...r };
483
487
  // Never force 'completed'. A fighter's history endpoint also
484
488
  // returns bouts that are booked but not yet fought, and forcing
485
489
  // the status told agents that a future main event had already
@@ -488,11 +492,16 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
488
492
  // reported as completed with result null). Let normalizeMatch
489
493
  // derive it from the result and the start time instead.
490
494
  const m = normalizeMatch('ufc', bout);
495
+ // Keep the normalized result OBJECT (method, round, time,
496
+ // winnerSlug), the same shape match_preview and match_summary
497
+ // return. This used to overwrite it with the string "win",
498
+ // giving the profile a fourth result schema of its own. The
499
+ // fighter's-eye view lives on outcome instead.
491
500
  return {
492
501
  ...m,
493
- result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
502
+ outcome: pickString(r.outcome)?.toLowerCase() ?? null,
494
503
  opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
495
- method: pickString(asRecord(bout)?.method, r.method) ?? null,
504
+ method: m.result?.method ?? null,
496
505
  };
497
506
  });
498
507
  }
@@ -508,7 +517,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
508
517
  .slice(0, recentLimit)
509
518
  .map((row) => {
510
519
  const r = asRecord(row) ?? {};
511
- const bout = asRecord(r.bout) ?? row;
520
+ const bout = { ...(asRecord(r.bout) ?? {}), ...r };
512
521
  // Same as above: derive, never assert. See the note there.
513
522
  return normalizeMatch('ufc', bout);
514
523
  });
@@ -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;
@@ -314,7 +317,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
314
317
  rateLimit: res.headers,
315
318
  });
316
319
  }
317
- let rows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
320
+ // Keep the unsliced count. UFC world scope is every division back to back,
321
+ // so limit=50 stops a third of the way through flyweight; a consumer needs
322
+ // to know that happened rather than receive total:null and guess.
323
+ let allRows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i));
324
+ let rows = allRows.slice(0, limit);
318
325
  // UFC rankings may be nested by division
319
326
  if (game === 'ufc' && rows.length === 0) {
320
327
  const obj = asRecord(res.data) ?? {};
@@ -326,7 +333,8 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
326
333
  nested.push(...asRecord(value).rankings);
327
334
  }
328
335
  }
329
- rows = nested.map((row, i) => normalizeStandingRow(row, i)).slice(0, limit);
336
+ allRows = nested.map((row, i) => normalizeStandingRow(row, i));
337
+ rows = allRows.slice(0, limit);
330
338
  }
331
339
  const obj = asRecord(res.data);
332
340
  const upstreamMeta = asRecord(obj?.meta) ?? {};
@@ -369,6 +377,8 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
369
377
  season: season ?? null,
370
378
  stage: stage ?? null,
371
379
  rows,
380
+ total: allRows.length,
381
+ hasMore: allRows.length > rows.length,
372
382
  updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
373
383
  null,
374
384
  ...(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) : [];
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.13",
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"