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 +3 -1
- package/dist/tools/insight.js +214 -1
- package/dist/tools/live.js +25 -0
- package/dist/tools/match.js +5 -2
- package/dist/tools/normalize.js +8 -4
- package/dist/tools/player.js +22 -10
- package/dist/tools/standings.js +28 -2
- package/dist/tools/team.js +3 -0
- package/package.json +1 -1
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
|
-
|
|
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';
|
package/dist/tools/insight.js
CHANGED
|
@@ -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');
|
package/dist/tools/live.js
CHANGED
|
@@ -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,
|
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
|
@@ -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
|
-
|
|
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 =
|
|
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);
|
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', {
|
|
@@ -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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
};
|
package/dist/tools/standings.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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,
|
package/dist/tools/team.js
CHANGED
|
@@ -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.
|
|
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": {
|