cito-mcp 0.3.12 → 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.
- package/dist/tools/insight.js +46 -6
- package/dist/tools/live.js +50 -8
- package/dist/tools/match.js +5 -2
- package/dist/tools/normalize.js +99 -8
- package/dist/tools/player.js +35 -14
- package/dist/tools/standings.js +39 -3
- package/dist/tools/team.js +7 -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,11 +205,15 @@ 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
|
|
208
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')
|
|
209
217
|
.filter((m) => {
|
|
210
218
|
const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
211
219
|
const s2 = [m.team2?.id, m.team2?.slug, m.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
@@ -219,7 +227,7 @@ function composeH2H(game, sideA, sideB, rows, limit) {
|
|
|
219
227
|
return {
|
|
220
228
|
meetings: meetings.length,
|
|
221
229
|
lastMeetings: meetings.slice(0, 5),
|
|
222
|
-
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',
|
|
223
231
|
};
|
|
224
232
|
}
|
|
225
233
|
export const matchPreview = {
|
|
@@ -406,8 +414,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
406
414
|
rateLimit = { ...rateLimit, ...hist.headers };
|
|
407
415
|
if (hist.ok) {
|
|
408
416
|
rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
|
|
417
|
+
// Keep fighterSlug/opponent/outcome alongside the bout; they are
|
|
418
|
+
// the only place the two corners exist on a history row.
|
|
409
419
|
const r = asRecord(row) ?? {};
|
|
410
|
-
return asRecord(r.bout) ??
|
|
420
|
+
return { ...(asRecord(r.bout) ?? {}), ...r };
|
|
411
421
|
});
|
|
412
422
|
}
|
|
413
423
|
else {
|
|
@@ -428,6 +438,31 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
428
438
|
}
|
|
429
439
|
}
|
|
430
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
|
+
}
|
|
431
466
|
const talkingPoints = [];
|
|
432
467
|
const sideAName = pickString(asRecord(sideARes.entity)?.name, teamA) ?? teamA;
|
|
433
468
|
const sideBName = pickString(asRecord(sideBRes.entity)?.name, teamB) ?? teamB;
|
|
@@ -757,6 +792,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
|
|
|
757
792
|
}));
|
|
758
793
|
}
|
|
759
794
|
}
|
|
795
|
+
// Bout rows carry no start time of their own; inherit the card's. Without
|
|
796
|
+
// this the same bout said startTime:null here and the event time on
|
|
797
|
+
// upcoming_schedule, and a caller had to fetch both to learn when it is.
|
|
798
|
+
const cardStart = typeof event.date === 'string' ? event.date : null;
|
|
799
|
+
bouts = bouts.map((b) => ({ ...b, startTime: b.startTime ?? cardStart }));
|
|
760
800
|
if (includeStandings) {
|
|
761
801
|
const ranks = await fetchJson(ctx, '/ufc/rankings');
|
|
762
802
|
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) {
|
|
@@ -306,12 +323,37 @@ Example: { "game": "all", "limitPerGame": 10 }`,
|
|
|
306
323
|
: undefined;
|
|
307
324
|
const supervisor = normalized.length === 0 && events.length ? slimUfcSupervisorEvents(events) : undefined;
|
|
308
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
|
+
}
|
|
309
349
|
sections.push({
|
|
310
350
|
game,
|
|
311
351
|
count: normalized.length,
|
|
312
352
|
ok: true,
|
|
353
|
+
state,
|
|
313
354
|
note,
|
|
314
355
|
emptyReason,
|
|
356
|
+
nextEvent,
|
|
315
357
|
health: healthStrip,
|
|
316
358
|
supervisor,
|
|
317
359
|
nextCard,
|
package/dist/tools/match.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
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
|
@@ -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
|
|
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', {
|
|
@@ -479,7 +481,11 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
479
481
|
.slice(0, recentLimit)
|
|
480
482
|
.map((row) => {
|
|
481
483
|
const r = asRecord(row) ?? {};
|
|
482
|
-
|
|
484
|
+
// Merge the row's own corner fields (fighterSlug / opponent /
|
|
485
|
+
// outcome) over the nested bout: they are the only place the two
|
|
486
|
+
// sides exist on a history row, and without them every entry
|
|
487
|
+
// came back team1:null, team2:null with the division as label.
|
|
488
|
+
const bout = { ...(asRecord(r.bout) ?? {}), ...r };
|
|
483
489
|
// Never force 'completed'. A fighter's history endpoint also
|
|
484
490
|
// returns bouts that are booked but not yet fought, and forcing
|
|
485
491
|
// the status told agents that a future main event had already
|
|
@@ -488,11 +494,16 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
488
494
|
// reported as completed with result null). Let normalizeMatch
|
|
489
495
|
// derive it from the result and the start time instead.
|
|
490
496
|
const m = normalizeMatch('ufc', bout);
|
|
497
|
+
// Keep the normalized result OBJECT (method, round, time,
|
|
498
|
+
// winnerSlug), the same shape match_preview and match_summary
|
|
499
|
+
// return. This used to overwrite it with the string "win",
|
|
500
|
+
// giving the profile a fourth result schema of its own. The
|
|
501
|
+
// fighter's-eye view lives on outcome instead.
|
|
491
502
|
return {
|
|
492
503
|
...m,
|
|
493
|
-
|
|
504
|
+
outcome: pickString(r.outcome)?.toLowerCase() ?? null,
|
|
494
505
|
opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
|
|
495
|
-
method:
|
|
506
|
+
method: m.result?.method ?? null,
|
|
496
507
|
};
|
|
497
508
|
});
|
|
498
509
|
}
|
|
@@ -508,7 +519,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
508
519
|
.slice(0, recentLimit)
|
|
509
520
|
.map((row) => {
|
|
510
521
|
const r = asRecord(row) ?? {};
|
|
511
|
-
const bout = asRecord(r.bout) ??
|
|
522
|
+
const bout = { ...(asRecord(r.bout) ?? {}), ...r };
|
|
512
523
|
// Same as above: derive, never assert. See the note there.
|
|
513
524
|
return normalizeMatch('ufc', bout);
|
|
514
525
|
});
|
|
@@ -542,15 +553,25 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
542
553
|
...(player.slug ? { slug: player.slug } : {}),
|
|
543
554
|
},
|
|
544
555
|
},
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
+
},
|
|
554
575
|
});
|
|
555
576
|
},
|
|
556
577
|
};
|
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;
|
|
@@ -122,6 +125,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
122
125
|
stage: stringSchema('Stage key (COD / LoL).'),
|
|
123
126
|
division: stringSchema('UFC division key.'),
|
|
124
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.'),
|
|
125
129
|
},
|
|
126
130
|
},
|
|
127
131
|
handler: async (args, ctx) => {
|
|
@@ -140,6 +144,9 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
140
144
|
}
|
|
141
145
|
const game = gameParse.game;
|
|
142
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);
|
|
143
150
|
const scope = typeof args.scope === 'string' ? args.scope : undefined;
|
|
144
151
|
const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
|
|
145
152
|
const tournamentId = typeof args.tournamentId === 'string' ? args.tournamentId : undefined;
|
|
@@ -314,7 +321,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
314
321
|
rateLimit: res.headers,
|
|
315
322
|
});
|
|
316
323
|
}
|
|
317
|
-
|
|
324
|
+
// Keep the unsliced count. UFC world scope is every division back to back,
|
|
325
|
+
// so limit=50 stops a third of the way through flyweight; a consumer needs
|
|
326
|
+
// to know that happened rather than receive total:null and guess.
|
|
327
|
+
let allRows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i));
|
|
328
|
+
let rows = allRows.slice(offset, offset + limit);
|
|
318
329
|
// UFC rankings may be nested by division
|
|
319
330
|
if (game === 'ufc' && rows.length === 0) {
|
|
320
331
|
const obj = asRecord(res.data) ?? {};
|
|
@@ -326,8 +337,30 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
326
337
|
nested.push(...asRecord(value).rankings);
|
|
327
338
|
}
|
|
328
339
|
}
|
|
329
|
-
|
|
340
|
+
allRows = nested.map((row, i) => normalizeStandingRow(row, i));
|
|
341
|
+
rows = allRows.slice(offset, offset + limit);
|
|
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
|
+
});
|
|
330
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
|
+
};
|
|
331
364
|
const obj = asRecord(res.data);
|
|
332
365
|
const upstreamMeta = asRecord(obj?.meta) ?? {};
|
|
333
366
|
const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
|
|
@@ -356,6 +389,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
356
389
|
: 'Rankings dataFreshness=cached or stale.');
|
|
357
390
|
}
|
|
358
391
|
return successEnvelope({
|
|
392
|
+
pagination: standingsPagination,
|
|
359
393
|
source: 'standings',
|
|
360
394
|
game,
|
|
361
395
|
requestId,
|
|
@@ -369,6 +403,8 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
369
403
|
season: season ?? null,
|
|
370
404
|
stage: stage ?? null,
|
|
371
405
|
rows,
|
|
406
|
+
total: allRows.length,
|
|
407
|
+
hasMore: allRows.length > rows.length,
|
|
372
408
|
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
|
|
373
409
|
null,
|
|
374
410
|
...(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) : [];
|
|
@@ -917,6 +920,9 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
917
920
|
}
|
|
918
921
|
const meetings = rows
|
|
919
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')
|
|
920
926
|
.filter((m) => sidesMatch(m, matchA, matchB))
|
|
921
927
|
.filter((m) => {
|
|
922
928
|
if (!fromIso && !toIso)
|
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.14",
|
|
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"
|