cito-mcp 0.3.10 → 0.3.12

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.
@@ -426,8 +426,31 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
426
426
  // also moves paging client-side, because an upstream offset would be
427
427
  // counted against rows we are about to discard.
428
428
  let clientTeamFilter = false;
429
+ // Set when this tool, not the upstream, is responsible for paging.
430
+ let clientDatePaging = false;
429
431
  let query = { limit, offset };
430
- if (game === 'lol') {
432
+ lolBranch: if (game === 'lol') {
433
+ // from/to means a date WINDOW, which /lol/schedule/upcoming cannot serve:
434
+ // it only knows `hours` forward from now, so asking for last month
435
+ // silently returned next week's fixtures with an "ignored filter"
436
+ // warning. /lol/schedule does accept from/to, so route there instead and
437
+ // let callers list completed matches.
438
+ if (from || to) {
439
+ path = '/lol/schedule';
440
+ clientDatePaging = true;
441
+ query = {
442
+ limit: String(Math.min(200, (offset + limit) * 4)),
443
+ ...(from ? { from } : {}),
444
+ ...(to ? { to } : {}),
445
+ ...(league ? { leagueSlug: league } : {}),
446
+ ...(team ? { teamSlug: team } : {}),
447
+ };
448
+ if (team)
449
+ clientTeamFilter = true;
450
+ if (tournamentId)
451
+ noteIgnored('tournamentId', 'use league filter or standings for LoL tournaments');
452
+ break lolBranch;
453
+ }
431
454
  path = '/lol/schedule/upcoming';
432
455
  // The upstream accepts teamSlug but does not apply it: asking for t1
433
456
  // returned Dplus KIA, Team WE and NightBirds. That is a silently wrong
@@ -437,10 +460,13 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
437
460
  // here instead.
438
461
  if (team)
439
462
  clientTeamFilter = true;
463
+ // The upstream accepts `offset` and ignores it, so page two came back
464
+ // byte for byte identical to page one while the cursor advanced in the
465
+ // metadata. Over-fetch and slice here instead of trusting it.
466
+ clientDatePaging = true;
440
467
  query = {
441
468
  hours: String(hours),
442
- limit: String(clientTeamFilter ? Math.min(200, (offset + limit) * 4) : limit),
443
- ...(clientTeamFilter ? {} : { offset: String(offset) }),
469
+ limit: String(Math.min(200, (offset + limit) * 4)),
444
470
  ...(league ? { leagueSlug: league } : {}),
445
471
  ...(team ? { teamSlug: team } : {}),
446
472
  };
@@ -664,8 +690,13 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
664
690
  if (team && beforeTeamFilter > 0 && rows.length === 0) {
665
691
  warnings.push(`team "${team}" matched 0 of ${beforeTeamFilter} upcoming fixtures in this window — widen hours or check the slug`);
666
692
  }
667
- const items = (clientTeamFilter ? rows.slice(offset, offset + limit) : rows.slice(0, limit))
668
- .map((row) => normalizeMatch(game, row, 'upcoming'));
693
+ const pageHere = clientTeamFilter || clientDatePaging;
694
+ const windowed = pageHere
695
+ ? rows.slice(Number(offset), Number(offset) + Number(limit))
696
+ : rows.slice(0, limit);
697
+ // A date window can contain finished matches, so do not force "upcoming"
698
+ // onto rows that already carry a real state.
699
+ const items = windowed.map((row) => from || to ? normalizeMatch(game, row) : normalizeMatch(game, row, 'upcoming'));
669
700
  const obj = asRecord(res.data);
670
701
  const total = typeof obj?.total === 'number' ? obj.total : null;
671
702
  const hasMore = (typeof obj?.hasMore === 'boolean' ? obj.hasMore : items.length >= limit) ||
@@ -389,6 +389,102 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
389
389
  });
390
390
  },
391
391
  };
392
+ /**
393
+ * Fold a UFC bout's raw odds payload into something an agent can read.
394
+ *
395
+ * The REST response is deliberately complete: every bookmaker, every market, up
396
+ * to roughly 950 outcomes for a single bout. Handing that back whole would bury
397
+ * the two numbers a caller almost always wants, and the server's own
398
+ * instructions say not to flood odds. So the moneyline is summarised per fighter
399
+ * and every other market is reduced to a count, with the raw endpoint named so
400
+ * anyone who genuinely needs the full book can go and get it.
401
+ *
402
+ * isAvailable is carried through as currentlyOffered rather than filtered on. It
403
+ * means "a book is offering this right now", so a finished fight's closing line
404
+ * comes back with it false, and that is information rather than absence.
405
+ * Filtering here would recreate the bug where whole settled cards looked like
406
+ * they had no odds at all.
407
+ */
408
+ export function summarizeUfcOdds(data) {
409
+ // fetchJson hands back the whole REST envelope ({ success, data, meta }), not
410
+ // the inner payload, so unwrap one level when it is there. Accepting both
411
+ // shapes because the unit tests feed the inner object directly; reading only
412
+ // the outer one returned a perfectly shaped, perfectly empty summary, which
413
+ // looked like "this bout has no odds" rather than like a bug.
414
+ const root = (data ?? {});
415
+ const payload = (root.data && typeof root.data === 'object' && !Array.isArray(root.data)
416
+ ? root.data
417
+ : root);
418
+ const markets = Array.isArray(payload.markets) ? payload.markets : [];
419
+ const byFighter = new Map();
420
+ const otherMarkets = new Map();
421
+ let observedAt = null;
422
+ for (const market of markets) {
423
+ const outcomes = Array.isArray(market.outcomes) ? market.outcomes : [];
424
+ for (const outcome of outcomes) {
425
+ const observed = typeof outcome.observedAt === 'string' ? outcome.observedAt : null;
426
+ if (observed && (!observedAt || observed > observedAt))
427
+ observedAt = observed;
428
+ const fighter = (outcome.fighter ?? {});
429
+ const bookmaker = (outcome.bookmaker ?? {});
430
+ const slug = typeof fighter.slug === 'string' ? fighter.slug : null;
431
+ const type = typeof outcome.marketType === 'string' ? outcome.marketType : 'unknown';
432
+ const book = typeof bookmaker.slug === 'string' ? bookmaker.slug : 'unknown';
433
+ if (type === 'moneyline' && slug) {
434
+ const entry = byFighter.get(slug) ?? {
435
+ slug,
436
+ name: typeof fighter.name === 'string' ? fighter.name : null,
437
+ side: typeof outcome.side === 'string' ? outcome.side : null,
438
+ prices: [],
439
+ books: new Set(),
440
+ live: false,
441
+ };
442
+ if (typeof outcome.moneyline === 'number')
443
+ entry.prices.push(outcome.moneyline);
444
+ entry.books.add(book);
445
+ if (outcome.isAvailable === true)
446
+ entry.live = true;
447
+ byFighter.set(slug, entry);
448
+ continue;
449
+ }
450
+ const other = otherMarkets.get(type) ?? { outcomes: 0, books: new Set() };
451
+ other.outcomes += 1;
452
+ other.books.add(book);
453
+ otherMarkets.set(type, other);
454
+ }
455
+ }
456
+ // American odds: the best price is the highest number both for an underdog
457
+ // (+250 beats +180) and for a favourite (-110 beats -200), so it is simply the
458
+ // maximum in both cases.
459
+ const moneyline = [...byFighter.values()].map((entry) => {
460
+ const sorted = [...entry.prices].sort((a, b) => a - b);
461
+ const mid = sorted.length
462
+ ? sorted.length % 2
463
+ ? sorted[(sorted.length - 1) / 2]
464
+ : Math.round((sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2)
465
+ : null;
466
+ return {
467
+ fighter: { slug: entry.slug, name: entry.name },
468
+ side: entry.side,
469
+ bookmakers: entry.books.size,
470
+ bestAmerican: sorted.length ? sorted[sorted.length - 1] : null,
471
+ medianAmerican: mid,
472
+ impliedProbability: mid === null ? null : Number((mid > 0 ? 100 / (mid + 100) : -mid / (-mid + 100)).toFixed(4)),
473
+ currentlyOffered: entry.live,
474
+ };
475
+ });
476
+ return {
477
+ moneyline,
478
+ otherMarkets: [...otherMarkets.entries()]
479
+ .map(([type, value]) => ({ type, outcomes: value.outcomes, bookmakers: value.books.size }))
480
+ .sort((a, b) => b.outcomes - a.outcomes),
481
+ observedAt,
482
+ notes: moneyline.some((row) => !row.currentlyOffered)
483
+ ? 'currentlyOffered=false means the market has closed; these are closing lines, not live prices.'
484
+ : undefined,
485
+ fullBookPath: '/ufc/bouts/{boutId}/odds',
486
+ };
487
+ }
392
488
  export const matchDetails = {
393
489
  name: 'match_details',
394
490
  description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
@@ -404,6 +500,7 @@ Prefer match_summary for short answers and default cards.
404
500
  Do not use when: first-pass live board (use live_matches + match_summary).
405
501
 
406
502
  Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
503
+ UFC betting lines: sections:["odds"] (opt-in, never in the default set).
407
504
  If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
408
505
 
409
506
  Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
@@ -420,9 +517,9 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
420
517
  type: 'array',
421
518
  items: {
422
519
  type: 'string',
423
- enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced'],
520
+ enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
424
521
  },
425
- description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media.',
522
+ description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" (UFC) is opt-in: moneyline summarised per fighter with bookmaker count, best and median American price and implied probability, plus a count of every other market. Closing lines for a finished fight come back with currentlyOffered=false rather than being omitted.',
426
523
  },
427
524
  includeTimeline: boolSchema('Include timeline section (heavy).', false),
428
525
  includeLiveState: boolSchema('Include live state/snapshots.', false),
@@ -476,12 +573,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
476
573
  base: null,
477
574
  playerStats: null,
478
575
  gamesOrMaps: null,
576
+ odds: null,
479
577
  timeline: null,
480
578
  liveState: null,
481
579
  media: null,
482
580
  advanced: null,
483
581
  };
484
- // base is primary
582
+ // base is ALWAYS fetched, whatever sections were asked for. Requesting
583
+ // sections:["timeline"] returned match:null, so a caller got a timeline with
584
+ // nothing to attach it to and no way to tell which match it belonged to.
585
+ // The header is the identity of the response, not an optional section.
586
+ wanted.add('base');
485
587
  if (wanted.has('base')) {
486
588
  const res = await getSection(ctx, primaryPath(game, matchId));
487
589
  upstreamCalls += 1;
@@ -520,6 +622,28 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
520
622
  }
521
623
  })());
522
624
  };
625
+ // Same as load(), but folds the payload through a shaper first. Raw odds are
626
+ // far too big to hand back whole: one bout carries up to ~950 outcomes across
627
+ // every bookmaker and prop market, which would bury the answer it was fetched
628
+ // to give.
629
+ const loadWith = (section, path, shape, query) => {
630
+ tasks.push((async () => {
631
+ const res = await getSection(ctx, path, query);
632
+ upstreamCalls += 1;
633
+ rateLimit = { ...rateLimit, ...res.headers };
634
+ if (!res.ok) {
635
+ partial.push(partialFromRejection(section, {
636
+ code: mapHttpToCode(res.status),
637
+ message: `${section} HTTP ${res.status}`,
638
+ httpStatus: res.status,
639
+ }));
640
+ sections[section] = null;
641
+ }
642
+ else {
643
+ sections[section] = shape(res.data);
644
+ }
645
+ })());
646
+ };
523
647
  if (wanted.has('playerStats')) {
524
648
  if (game === 'lol')
525
649
  load('playerStats', `/lol/matches/${encodeURIComponent(matchId)}/player-stats`);
@@ -573,6 +697,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
573
697
  }));
574
698
  }
575
699
  }
700
+ if (wanted.has('odds')) {
701
+ if (game === 'ufc') {
702
+ loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
703
+ }
704
+ else {
705
+ partial.push(partialFromRejection('odds', {
706
+ code: 'NOT_IMPLEMENTED',
707
+ message: `Odds not curated for ${game}; UFC only today`,
708
+ }));
709
+ }
710
+ }
576
711
  if (wanted.has('media')) {
577
712
  if (game === 'cs2')
578
713
  load('media', `/cs2/matches/${encodeURIComponent(matchId)}/demos`);
@@ -8,8 +8,15 @@ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSc
8
8
  function teamIdentity(game, raw, idHint, slugHint) {
9
9
  const r = asRecord(raw) ?? {};
10
10
  const nested = asRecord(r.data) ?? r;
11
- const id = pickString(nested.id, nested.teamId, idHint, slugHint) ?? 'unknown';
12
11
  const slug = pickString(nested.slug, nested.orgSlug, slugHint);
12
+ // When the caller asks for an alias the API resolves it, and the resolved
13
+ // slug is the truth. Falling back to the requested value for `id` produced
14
+ // id:"t1a" beside slug:"t1-challengers", a row that disagreed with itself.
15
+ const resolved = pickString(nested.slug, nested.orgSlug);
16
+ const id = pickString(nested.id, nested.teamId) ??
17
+ resolved ??
18
+ pickString(idHint, slugHint) ??
19
+ 'unknown';
13
20
  const name = pickString(nested.name, nested.tag, nested.code, slug, id) ?? id;
14
21
  return {
15
22
  id,
@@ -301,8 +308,26 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
301
308
  const t = Date.parse(String(row?.startTime ?? ''));
302
309
  return Number.isFinite(t) ? t : -Infinity;
303
310
  };
311
+ // RECENT means finished AND in the past. The index also carries
312
+ // scheduled fixtures, and this path forced every row to
313
+ // "completed", so Gen.G and HLE both listed their 2026-09-05
314
+ // playoff match as a completed result days before it was played.
315
+ const nowMs = Date.now();
316
+ const isFinished = (row) => {
317
+ const r = asRecord(row) ?? {};
318
+ const t = Date.parse(String(r.startTime ?? ''));
319
+ if (Number.isFinite(t) && t > nowMs)
320
+ return false;
321
+ const state = String(r.state ?? r.status ?? '').toLowerCase();
322
+ if (/unstarted|scheduled|upcoming|not_?started|in_?progress|live/.test(state))
323
+ return false;
324
+ // No usable state: fall back to the clock plus a decided result.
325
+ if (!state)
326
+ return Number.isFinite(t) && t <= nowMs;
327
+ return /complete|finish|final|ended/.test(state);
328
+ };
304
329
  recentMatches = extractRows(res.data)
305
- .slice()
330
+ .filter(isFinished)
306
331
  .sort((a, b) => startMs(b) - startMs(a))
307
332
  .slice(0, recentLimit)
308
333
  .map((row) => normalizeMatch('lol', row, 'completed'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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": {