cito-mcp 0.3.13 → 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.
@@ -210,6 +210,10 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
210
210
  const bl = sideB.toLowerCase();
211
211
  const meetings = rows
212
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')
213
217
  .filter((m) => {
214
218
  const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
215
219
  const s2 = [m.team2?.id, m.team2?.slug, m.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
@@ -223,7 +227,7 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
223
227
  return {
224
228
  meetings: meetings.length,
225
229
  lastMeetings: meetings.slice(0, 5),
226
- 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',
227
231
  };
228
232
  }
229
233
  export const matchPreview = {
@@ -434,6 +438,31 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
434
438
  }
435
439
  }
436
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
+ }
437
466
  const talkingPoints = [];
438
467
  const sideAName = pickString(asRecord(sideARes.entity)?.name, teamA) ?? teamA;
439
468
  const sideBName = pickString(asRecord(sideBRes.entity)?.name, teamB) ?? teamB;
@@ -323,12 +323,37 @@ Example: { "game": "all", "limitPerGame": 10 }`,
323
323
  : undefined;
324
324
  const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
325
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
+ }
326
349
  sections.push({
327
350
  game,
328
351
  count: normalized.length,
329
352
  ok: true,
353
+ state,
330
354
  note,
331
355
  emptyReason,
356
+ nextEvent,
332
357
  health: healthStrip,
333
358
  supervisor,
334
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.
@@ -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', {
@@ -551,15 +553,25 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
551
553
  ...(player.slug ? { slug: player.slug } : {}),
552
554
  },
553
555
  },
554
- data: {
555
- player,
556
- currentTeam,
557
- recentMatches,
558
- form,
559
- career,
560
- radar,
561
- seasonStats,
562
- },
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
+ },
563
575
  });
564
576
  },
565
577
  };
@@ -125,6 +125,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
125
125
  stage: stringSchema('Stage key (COD / LoL).'),
126
126
  division: stringSchema('UFC division key.'),
127
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.'),
128
129
  },
129
130
  },
130
131
  handler: async (args, ctx) => {
@@ -143,6 +144,9 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
143
144
  }
144
145
  const game = gameParse.game;
145
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);
146
150
  const scope = typeof args.scope === 'string' ? args.scope : undefined;
147
151
  const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
148
152
  const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
@@ -321,7 +325,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
321
325
  // so limit=50 stops a third of the way through flyweight; a consumer needs
322
326
  // to know that happened rather than receive total:null and guess.
323
327
  let allRows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i));
324
- let rows = allRows.slice(0, limit);
328
+ let rows = allRows.slice(offset, offset + limit);
325
329
  // UFC rankings may be nested by division
326
330
  if (game === 'ufc' && rows.length === 0) {
327
331
  const obj = asRecord(res.data) ?? {};
@@ -334,8 +338,29 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
334
338
  }
335
339
  }
336
340
  allRows = nested.map((row, i) => normalizeStandingRow(row, i));
337
- rows = allRows.slice(0, limit);
341
+ rows = allRows.slice(offset, offset + limit);
338
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
+ });
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
+ };
339
364
  const obj = asRecord(res.data);
340
365
  const upstreamMeta = asRecord(obj?.meta) ?? {};
341
366
  const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
@@ -364,6 +389,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
364
389
  : 'Rankings dataFreshness=cached or stale.');
365
390
  }
366
391
  return successEnvelope({
392
+ pagination: standingsPagination,
367
393
  source: 'standings',
368
394
  game,
369
395
  requestId,
@@ -920,6 +920,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
920
920
  }
921
921
  const meetings = rows
922
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')
923
926
  .filter((m) => sidesMatch(m, matchA, matchB))
924
927
  .filter((m) => {
925
928
  if (!fromIso && !toIso)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
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": {