cito-mcp 0.3.14 → 0.3.16
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 +184 -0
- package/dist/tools/normalize.js +14 -5
- package/package.json +2 -2
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;
|
|
@@ -315,6 +359,8 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
315
359
|
path = `/cod/matches/${encodeURIComponent(matchId)}`;
|
|
316
360
|
else if (game === 'ufc')
|
|
317
361
|
path = `/ufc/bouts/${encodeURIComponent(matchId)}`;
|
|
362
|
+
else if (game === 'tennis')
|
|
363
|
+
path = `/tennis/matches/${encodeURIComponent(matchId)}`;
|
|
318
364
|
const res = await fetchJson(ctx, path);
|
|
319
365
|
upstreamCalls += 1;
|
|
320
366
|
rateLimit = res.headers;
|
|
@@ -336,6 +382,18 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
336
382
|
teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
|
|
337
383
|
if (!teamB)
|
|
338
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
|
+
}
|
|
339
397
|
}
|
|
340
398
|
else {
|
|
341
399
|
partial.push(partialFromRejection('context', {
|
|
@@ -405,6 +463,45 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
405
463
|
if (res.ok)
|
|
406
464
|
rows = extractRows(res.data);
|
|
407
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
|
+
}
|
|
408
505
|
else if (game === 'ufc') {
|
|
409
506
|
// Fighter fight history is the H2H source of truth (not global /bouts page 1).
|
|
410
507
|
const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
|
|
@@ -548,6 +645,32 @@ async function resolveEventKey(ctx, game, q) {
|
|
|
548
645
|
const needle = q.toLowerCase().trim();
|
|
549
646
|
if (!needle)
|
|
550
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
|
+
}
|
|
551
674
|
let path = '';
|
|
552
675
|
let query = { limit: 25, page: 1 };
|
|
553
676
|
if (game === 'ufc') {
|
|
@@ -1085,6 +1208,67 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
|
|
|
1085
1208
|
}
|
|
1086
1209
|
}
|
|
1087
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
|
+
}
|
|
1088
1272
|
else {
|
|
1089
1273
|
// dota2 — thin: tournament list + recent matches only
|
|
1090
1274
|
warnings.push('Dota2 event_card is thin; expect partial bouts/standings');
|
package/dist/tools/normalize.js
CHANGED
|
@@ -323,11 +323,18 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
323
323
|
team1 = team1 ?? nestedSide(r.teams[0], game);
|
|
324
324
|
team2 = team2 ?? nestedSide(r.teams[1], game);
|
|
325
325
|
}
|
|
326
|
-
const startTime = pickString(r.startTime, r.scheduledAt,
|
|
327
|
-
|
|
326
|
+
const startTime = pickString(r.startTime, r.scheduledAt,
|
|
327
|
+
// Tennis emits scheduled_at; startTime was the API's only camelCase key
|
|
328
|
+
// and was removed. Without this the upcoming board loses its clock.
|
|
329
|
+
r.scheduled_at,
|
|
330
|
+
// A live row reports when it went in-play, not when it was scheduled.
|
|
331
|
+
r.started_at, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
|
|
332
|
+
// Tennis archive rows carry the state as outcome (COMPLETED / RETIREMENT /
|
|
333
|
+
// WALKOVER); read it, or every finished match reported status unknown.
|
|
334
|
+
const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus, r.outcome)?.toLowerCase() ?? '';
|
|
328
335
|
let status = forcedStatus ?? 'unknown';
|
|
329
336
|
if (!forcedStatus) {
|
|
330
|
-
const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.method, r.result)) ||
|
|
337
|
+
const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id, r.method, r.result)) ||
|
|
331
338
|
r.resultRound != null ||
|
|
332
339
|
r.isComplete === true ||
|
|
333
340
|
r.completed === true;
|
|
@@ -348,7 +355,7 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
348
355
|
status = 'live';
|
|
349
356
|
else if (looksLive && !kickoffPassed)
|
|
350
357
|
status = 'upcoming';
|
|
351
|
-
else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
|
|
358
|
+
else if (/complete|finished|ended|final|closed|done|official|result|retire|walkover|default/.test(statusRaw) || hasResult) {
|
|
352
359
|
status = 'completed';
|
|
353
360
|
}
|
|
354
361
|
else if (/upcoming|scheduled|not_?started|unstarted|pending|soon|booked|confirmed|announced|tbd/.test(statusRaw) ||
|
|
@@ -374,7 +381,9 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
374
381
|
// as a win in head_to_head's tally, which reads the scores.
|
|
375
382
|
const historyOutcome = pickString(r.outcome)?.toLowerCase();
|
|
376
383
|
const historyOpponent = pickString(asRecord(r.opponent)?.slug);
|
|
377
|
-
const resolvedWinnerSlug =
|
|
384
|
+
const resolvedWinnerSlug =
|
|
385
|
+
// winner_id is the tennis archive's spelling; winner there is an object.
|
|
386
|
+
pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
|
|
378
387
|
(historyOutcome === 'win' && typeof r.fighterSlug === 'string' ? r.fighterSlug
|
|
379
388
|
: historyOutcome === 'loss' && historyOpponent ? historyOpponent
|
|
380
389
|
: undefined);
|
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.16",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports API — 15 curated outcome tools for agents (live, schedule, profiles, standings, previews, event cards).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cito-mcp": "dist/index.js"
|