cito-mcp 0.3.11 → 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.
- package/dist/tools/insight.js +16 -5
- package/dist/tools/live.js +25 -8
- package/dist/tools/match.js +133 -2
- package/dist/tools/normalize.js +99 -8
- package/dist/tools/player.js +13 -4
- package/dist/tools/standings.js +13 -3
- package/dist/tools/team.js +4 -1
- package/package.json +2 -2
package/dist/tools/insight.js
CHANGED
|
@@ -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
|
-
//
|
|
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) ??
|
|
53
|
-
return normalizeMatch('ufc', { ...bout, ...
|
|
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) ??
|
|
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;
|
package/dist/tools/live.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
?
|
|
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) {
|
package/dist/tools/match.js
CHANGED
|
@@ -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/dist/tools/normalize.js
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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,
|
package/dist/tools/player.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
502
|
+
outcome: pickString(r.outcome)?.toLowerCase() ?? null,
|
|
494
503
|
opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
|
|
495
|
-
method:
|
|
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) ??
|
|
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
|
});
|
package/dist/tools/standings.js
CHANGED
|
@@ -21,7 +21,10 @@ export function normalizeStandingRow(row, index) {
|
|
|
21
21
|
isInterim;
|
|
22
22
|
let rank;
|
|
23
23
|
if (isChampion) {
|
|
24
|
-
rank
|
|
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
|
-
|
|
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
|
-
|
|
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 } : {}),
|
package/dist/tools/team.js
CHANGED
|
@@ -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) ??
|
|
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.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports API
|
|
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"
|