cito-mcp 0.3.14 → 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 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
- if (status === 400)
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';
@@ -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');
@@ -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
- const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
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 = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ??
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.14",
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": {