cito-mcp 0.3.11 → 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.
Files changed (2) hide show
  1. package/dist/tools/match.js +133 -2
  2. package/package.json +1 -1
@@ -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,6 +573,7 @@ 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,
@@ -524,6 +622,28 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
524
622
  }
525
623
  })());
526
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
+ };
527
647
  if (wanted.has('playerStats')) {
528
648
  if (game === 'lol')
529
649
  load('playerStats', `/lol/matches/${encodeURIComponent(matchId)}/player-stats`);
@@ -577,6 +697,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
577
697
  }));
578
698
  }
579
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
+ }
580
711
  if (wanted.has('media')) {
581
712
  if (game === 'cs2')
582
713
  load('media', `/cs2/matches/${encodeURIComponent(matchId)}/demos`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.11",
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": {