cito-mcp 0.3.13 → 0.3.15

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/dist/envelope.js CHANGED
@@ -64,7 +64,9 @@ export function mapHttpToCode(status, hints) {
64
64
  }
65
65
  if (status === 400 && hints?.ambiguous)
66
66
  return 'AMBIGUOUS_ENTITY';
67
- if (status === 400)
67
+ // 422 is FastAPI's validation status (tennis). It was falling through to
68
+ // UPSTREAM, which is marked retryable, so an agent retried a bad argument.
69
+ if (status === 400 || status === 422)
68
70
  return 'VALIDATION';
69
71
  if (status >= 500 || status === 0)
70
72
  return 'UPSTREAM';
@@ -13,6 +13,50 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
13
13
  let roster = [];
14
14
  let recentForm = [];
15
15
  let keyPlayers = [];
16
+ if (game === 'tennis') {
17
+ // Profile + match log. Without this branch the preview fell to the generic
18
+ // fallback and presented player ids where names belong.
19
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(side)}`);
20
+ calls += 1;
21
+ rateLimit = res.headers;
22
+ if (res.ok) {
23
+ const data = asRecord(unwrapPayload(res.data)) ?? {};
24
+ const career = asRecord(data.career_summary) ?? {};
25
+ entity = {
26
+ id: pickString(data.player_id, data.id, side),
27
+ slug: pickString(data.player_id, data.id, side),
28
+ name: pickString(data.full_name, data.name, side) ?? side,
29
+ tour: pickString(data.tour) ?? null,
30
+ country: pickString(data.ioc, data.country_code) ?? null,
31
+ hand: pickString(data.hand) ?? null,
32
+ age: typeof data.age === 'number' ? data.age : null,
33
+ currentRank: typeof data.current_rank === 'number' ? data.current_rank : null,
34
+ careerHighRank: typeof data.career_high_rank === 'number' ? data.career_high_rank : null,
35
+ careerRecord: career.matches_won != null && career.matches_lost != null
36
+ ? `${career.matches_won}-${career.matches_lost}`
37
+ : null,
38
+ game,
39
+ };
40
+ }
41
+ else {
42
+ partial.push(partialFromRejection('entity', {
43
+ code: mapHttpToCode(res.status),
44
+ message: `player HTTP ${res.status}`,
45
+ httpStatus: res.status,
46
+ }));
47
+ }
48
+ const log = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(side)}/matches`, {
49
+ query: { page_size: recentLimit },
50
+ });
51
+ calls += 1;
52
+ rateLimit = { ...rateLimit, ...log.headers };
53
+ if (log.ok) {
54
+ recentForm = extractRows(unwrapPayload(log.data) ?? log.data)
55
+ .slice(0, recentLimit)
56
+ .map((row) => normalizeMatch('tennis', row));
57
+ }
58
+ return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
59
+ }
16
60
  if (game === 'ufc') {
17
61
  const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}`);
18
62
  calls += 1;
@@ -210,6 +254,10 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
210
254
  const bl = sideB.toLowerCase();
211
255
  const meetings = rows
212
256
  .map((row) => normalizeMatch(game, row))
257
+ // A meeting is a fight that happened. The upcoming bout between the two
258
+ // sides sits in the same history list and must not count as a prior
259
+ // meeting, or every preview reports the fight it is previewing as H2H.
260
+ .filter((m) => m.status === 'completed')
213
261
  .filter((m) => {
214
262
  const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
215
263
  const s2 = [m.team2?.id, m.team2?.slug, m.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
@@ -223,7 +271,7 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
223
271
  return {
224
272
  meetings: meetings.length,
225
273
  lastMeetings: meetings.slice(0, 5),
226
- recordNote: meetings.length ? `${meetings.length} meetings found in window` : 'No H2H meetings in window',
274
+ recordNote: meetings.length ? `${meetings.length} past meetings found in window` : 'No past H2H meetings in window',
227
275
  };
228
276
  }
229
277
  export const matchPreview = {
@@ -311,6 +359,8 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
311
359
  path = `/cod/matches/${encodeURIComponent(matchId)}`;
312
360
  else if (game === 'ufc')
313
361
  path = `/ufc/bouts/${encodeURIComponent(matchId)}`;
362
+ else if (game === 'tennis')
363
+ path = `/tennis/matches/${encodeURIComponent(matchId)}`;
314
364
  const res = await fetchJson(ctx, path);
315
365
  upstreamCalls += 1;
316
366
  rateLimit = res.headers;
@@ -332,6 +382,18 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
332
382
  teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
333
383
  if (!teamB)
334
384
  teamB = m.team2?.slug || m.team2?.id || m.team2?.name || '';
385
+ // Tennis archive detail carries winner/loser objects; if normalisation
386
+ // did not surface them as sides, read the ids straight off the record
387
+ // rather than failing the whole preview.
388
+ if (game === 'tennis') {
389
+ const rec = asRecord(entity) ?? {};
390
+ const w = asRecord(rec.winner) ?? asRecord(rec.player1) ?? {};
391
+ const l = asRecord(rec.loser) ?? asRecord(rec.player2) ?? {};
392
+ if (!teamA)
393
+ teamA = pickString(w.id, rec.winner_id, rec.player1_id) ?? '';
394
+ if (!teamB)
395
+ teamB = pickString(l.id, rec.loser_id, rec.player2_id) ?? '';
396
+ }
335
397
  }
336
398
  else {
337
399
  partial.push(partialFromRejection('context', {
@@ -401,6 +463,45 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
401
463
  if (res.ok)
402
464
  rows = extractRows(res.data);
403
465
  }
466
+ else if (game === 'tennis') {
467
+ // Tennis has a first-class H2H endpoint with the full rivalry; use it
468
+ // rather than composing from a short form window, which reported
469
+ // Sinner vs Alcaraz as never having met.
470
+ const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: { player1_id: teamA, player2_id: teamB } });
471
+ upstreamCalls += 1;
472
+ rateLimit = { ...rateLimit, ...h2hRes.headers };
473
+ if (h2hRes.ok) {
474
+ const rec = asRecord(unwrapPayload(h2hRes.data)) ?? {};
475
+ // The payload names both players once at the top; rows name only
476
+ // the winner. Resolve the loser's name from the top-level blocks
477
+ // so a meeting reads "Sinner vs Alcaraz", not "Sinner vs atp_207989".
478
+ const p1 = asRecord(rec.player1) ?? {};
479
+ const p2 = asRecord(rec.player2) ?? {};
480
+ const nameOf = {
481
+ [String(pickString(p1.id) ?? '')]: pickString(p1.name),
482
+ [String(pickString(p2.id) ?? '')]: pickString(p2.name),
483
+ };
484
+ rows = (Array.isArray(rec.matches) ? rec.matches : []).map((row) => {
485
+ const r = asRecord(row) ?? {};
486
+ const winnerId = pickString(r.winner_id) ?? '';
487
+ const loserId = winnerId === pickString(p1.id) ? String(pickString(p2.id) ?? teamB) : String(pickString(p1.id) ?? teamA);
488
+ return {
489
+ ...r,
490
+ outcome: 'COMPLETED',
491
+ winner: { id: winnerId, name: pickString(r.winner_name) ?? nameOf[winnerId] },
492
+ loser: { id: loserId, name: nameOf[loserId] },
493
+ startTime: pickString(r.date),
494
+ };
495
+ });
496
+ }
497
+ else {
498
+ partial.push(partialFromRejection('h2h', {
499
+ code: mapHttpToCode(h2hRes.status),
500
+ message: `tennis h2h HTTP ${h2hRes.status}`,
501
+ httpStatus: h2hRes.status,
502
+ }));
503
+ }
504
+ }
404
505
  else if (game === 'ufc') {
405
506
  // Fighter fight history is the H2H source of truth (not global /bouts page 1).
406
507
  const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
@@ -434,6 +535,31 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
434
535
  }
435
536
  }
436
537
  // Factual briefing lines only — never structural meta like "roster loaded".
538
+ // Called with teamA + teamB and no matchId, context stayed empty
539
+ // ({ matchId:null, event:null, startTime:null }) for a headliner whose
540
+ // own form list had all three. Side A's history carries the booked bout
541
+ // against side B; that IS the context.
542
+ if (!matchId && context.matchId == null) {
543
+ const bl = String(teamB ?? '').toLowerCase();
544
+ const booked = sideARes.recentForm.find((m) => {
545
+ if (!m || (m.status !== 'upcoming' && m.status !== 'live'))
546
+ return false;
547
+ const sides = [m.team1?.slug, m.team1?.id, m.team1?.name, m.team2?.slug, m.team2?.id, m.team2?.name]
548
+ .filter(Boolean).map((x) => String(x).toLowerCase());
549
+ return sides.some((x) => x === bl || x.includes(bl) || bl.includes(x));
550
+ });
551
+ if (booked) {
552
+ context = {
553
+ ...context,
554
+ matchId: booked.matchId !== 'unknown' ? booked.matchId : null,
555
+ startTime: booked.startTime,
556
+ event: booked.event,
557
+ eventName: booked.event?.name ?? null,
558
+ status: booked.status,
559
+ label: booked.label,
560
+ };
561
+ }
562
+ }
437
563
  const talkingPoints = [];
438
564
  const sideAName = pickString(asRecord(sideARes.entity)?.name, teamA) ?? teamA;
439
565
  const sideBName = pickString(asRecord(sideBRes.entity)?.name, teamB) ?? teamB;
@@ -519,6 +645,32 @@ async function resolveEventKey(ctx, game, q) {
519
645
  const needle = q.toLowerCase().trim();
520
646
  if (!needle)
521
647
  return { key: null, name: null, calls, rateLimit };
648
+ if (game === 'tennis') {
649
+ // No free-text search on tournaments upstream; the season calendar is the
650
+ // complete list and is small enough to match locally.
651
+ // This year's calendar first, then the two before it: the archive lags
652
+ // the live season, so the current edition of an event may not exist yet
653
+ // (no 2026 Wimbledon row while 2025 does).
654
+ const thisYear = new Date().getUTCFullYear();
655
+ let hit;
656
+ for (const year of [thisYear, thisYear - 1, thisYear - 2]) {
657
+ const res = await fetchJson(ctx, '/tennis/tournaments/calendar', { query: { year } });
658
+ calls += 1;
659
+ rateLimit = res.headers;
660
+ const rows = extractRows(unwrapPayload(res.data) ?? res.data).map((r) => asRecord(r) ?? {});
661
+ hit =
662
+ rows.find((r) => String(pickString(r.name) ?? '').toLowerCase() === needle) ??
663
+ rows.find((r) => String(pickString(r.name) ?? '').toLowerCase().includes(needle));
664
+ if (hit)
665
+ break;
666
+ }
667
+ return {
668
+ key: hit ? (pickString(hit.id, hit.tournament_id) ?? null) : null,
669
+ name: hit ? (pickString(hit.name) ?? null) : null,
670
+ calls,
671
+ rateLimit,
672
+ };
673
+ }
522
674
  let path = '';
523
675
  let query = { limit: 25, page: 1 };
524
676
  if (game === 'ufc') {
@@ -1056,6 +1208,67 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
1056
1208
  }
1057
1209
  }
1058
1210
  }
1211
+ else if (game === 'tennis') {
1212
+ // Tournament detail plus the bracket. The draw is only populated once a
1213
+ // tournament has results, so an event still in the future comes back
1214
+ // with the detail and an empty bouts list, and says so.
1215
+ const detail = await fetchJson(ctx, `/tennis/tournaments/${encodeURIComponent(eventKey)}`);
1216
+ upstreamCalls += 1;
1217
+ rateLimit = { ...rateLimit, ...detail.headers };
1218
+ if (detail.ok) {
1219
+ const d = asRecord(unwrapPayload(detail.data)) ?? {};
1220
+ event = {
1221
+ ...event,
1222
+ id: pickString(d.id) ?? eventKey,
1223
+ slug: pickString(d.id) ?? eventKey,
1224
+ name: pickString(d.name) ?? event.name,
1225
+ tour: pickString(d.tour) ?? null,
1226
+ level: pickString(d.level) ?? null,
1227
+ tier: pickString(d.tier) ?? null,
1228
+ surface: pickString(d.surface) ?? null,
1229
+ drawSize: typeof d.draw_size === 'number' ? d.draw_size : null,
1230
+ city: pickString(d.city) ?? null,
1231
+ country: pickString(d.country_code) ?? null,
1232
+ editions: { first: d.first_edition_year ?? null, mostRecent: d.most_recent_edition_year ?? null },
1233
+ allTimeTitleLeader: d.all_time_title_leader ?? null,
1234
+ };
1235
+ }
1236
+ else {
1237
+ partial.push(partialFromRejection('event', {
1238
+ code: mapHttpToCode(detail.status),
1239
+ message: `tournament HTTP ${detail.status}`,
1240
+ httpStatus: detail.status,
1241
+ }));
1242
+ }
1243
+ if (includeBouts) {
1244
+ const draw = await fetchJson(ctx, `/tennis/tournaments/${encodeURIComponent(eventKey)}/draw`);
1245
+ upstreamCalls += 1;
1246
+ rateLimit = { ...rateLimit, ...draw.headers };
1247
+ if (draw.ok) {
1248
+ const dd = asRecord(unwrapPayload(draw.data)) ?? {};
1249
+ const rounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1250
+ const flat = [];
1251
+ for (const rr of rounds) {
1252
+ const rec = asRecord(rr) ?? {};
1253
+ const roundName = pickString(rec.round_name, rec.name, rec.round) ?? null;
1254
+ for (const mm of (Array.isArray(rec.matches) ? rec.matches : [])) {
1255
+ const mr = asRecord(mm) ?? {};
1256
+ flat.push({ ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' });
1257
+ }
1258
+ }
1259
+ bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
1260
+ if (!bouts.length)
1261
+ warnings.push('No draw yet: the tournament has no archived results');
1262
+ }
1263
+ else {
1264
+ partial.push(partialFromRejection('bouts', {
1265
+ code: mapHttpToCode(draw.status),
1266
+ message: `tournament draw HTTP ${draw.status}`,
1267
+ httpStatus: draw.status,
1268
+ }));
1269
+ }
1270
+ }
1271
+ }
1059
1272
  else {
1060
1273
  // dota2 — thin: tournament list + recent matches only
1061
1274
  warnings.push('Dota2 event_card is thin; expect partial bouts/standings');
@@ -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.
@@ -324,10 +324,12 @@ export function normalizeMatch(game, row, forcedStatus) {
324
324
  team2 = team2 ?? nestedSide(r.teams[1], game);
325
325
  }
326
326
  const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
327
- const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
327
+ // Tennis archive rows carry the state as outcome (COMPLETED / RETIREMENT /
328
+ // WALKOVER); read it, or every finished match reported status unknown.
329
+ const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus, r.outcome)?.toLowerCase() ?? '';
328
330
  let status = forcedStatus ?? 'unknown';
329
331
  if (!forcedStatus) {
330
- const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.method, r.result)) ||
332
+ const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id, r.method, r.result)) ||
331
333
  r.resultRound != null ||
332
334
  r.isComplete === true ||
333
335
  r.completed === true;
@@ -348,7 +350,7 @@ export function normalizeMatch(game, row, forcedStatus) {
348
350
  status = 'live';
349
351
  else if (looksLive && !kickoffPassed)
350
352
  status = 'upcoming';
351
- else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
353
+ else if (/complete|finished|ended|final|closed|done|official|result|retire|walkover|default/.test(statusRaw) || hasResult) {
352
354
  status = 'completed';
353
355
  }
354
356
  else if (/upcoming|scheduled|not_?started|unstarted|pending|soon|booked|confirmed|announced|tbd/.test(statusRaw) ||
@@ -374,7 +376,9 @@ export function normalizeMatch(game, row, forcedStatus) {
374
376
  // as a win in head_to_head's tally, which reads the scores.
375
377
  const historyOutcome = pickString(r.outcome)?.toLowerCase();
376
378
  const historyOpponent = pickString(asRecord(r.opponent)?.slug);
377
- const resolvedWinnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ??
379
+ const resolvedWinnerSlug =
380
+ // winner_id is the tennis archive's spelling; winner there is an object.
381
+ pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
378
382
  (historyOutcome === 'win' && typeof r.fighterSlug === 'string' ? r.fighterSlug
379
383
  : historyOutcome === 'loss' && historyOpponent ? historyOpponent
380
384
  : undefined);
@@ -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.15",
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": {