cito-mcp 0.3.16 → 0.3.17

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.
@@ -60,7 +60,7 @@ Live board: api_health (optional) → live_matches → match_summary for selecte
60
60
  UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
61
61
  Team page: resolve_entity {type:team} → team_profile.
62
62
  Player card: resolve_entity {type:player|fighter} → player_profile.
63
- Fight night / event card: resolve_entity {type:event} → event_card {includeBouts:true} → match_preview for a featured bout.
63
+ Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
64
64
  Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
65
65
  App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
66
66
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch, sortByCardOrder } from './normalize.js';
6
+ import { normalizeMatch, presentSides, sortByCardOrder } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
8
8
  async function loadSide(ctx, game, side, recentLimit, includeRosters) {
9
9
  const partial = [];
@@ -270,7 +270,9 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
270
270
  .slice(0, limit);
271
271
  return {
272
272
  meetings: meetings.length,
273
- lastMeetings: meetings.slice(0, 5),
273
+ // Tennis has players, not teams: rename team1/team2 -> player1/player2 on
274
+ // the way out. Every other game keeps team1/team2 unchanged.
275
+ lastMeetings: meetings.slice(0, 5).map((m) => presentSides(m, game)),
274
276
  recordNote: meetings.length ? `${meetings.length} past meetings found in window` : 'No past H2H meetings in window',
275
277
  };
276
278
  }
@@ -620,14 +622,17 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
620
622
  context,
621
623
  sideA: {
622
624
  entity: sideARes.entity,
623
- roster: sideARes.roster,
624
- recentForm: sideARes.recentForm,
625
+ // A tennis player has no roster; the key never applied here, not
626
+ // merely came back empty. UFC fighters get the same treatment
627
+ // elsewhere in this surface — omit rather than ship a foreign key.
628
+ ...(game === 'tennis' ? {} : { roster: sideARes.roster }),
629
+ recentForm: sideARes.recentForm.map((m) => presentSides(m, game)),
625
630
  keyPlayers: sideARes.keyPlayers,
626
631
  },
627
632
  sideB: {
628
633
  entity: sideBRes.entity,
629
- roster: sideBRes.roster,
630
- recentForm: sideBRes.recentForm,
634
+ ...(game === 'tennis' ? {} : { roster: sideBRes.roster }),
635
+ recentForm: sideBRes.recentForm.map((m) => presentSides(m, game)),
631
636
  keyPlayers: sideBRes.keyPlayers,
632
637
  },
633
638
  h2h,
@@ -744,8 +749,12 @@ Prefer match_preview for one bout/match briefing; match_summary for completed re
744
749
 
745
750
  Do not use when: you only need live scores (live_matches); single finished match recap (match_summary); no event name/id yet and game unknown.
746
751
 
752
+ Tennis: pass a tournament id/slug to get the draw bracket — rounds carry a round
753
+ code (Q1..R128, QF, SF, F) with each match's players and score. Set is the
754
+ primary tennis path here; there is no separate draw tool.
755
+
747
756
  Parallel-safe: yes. Upstream cost: 1–4.
748
- Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "includeStandings": false }`,
757
+ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "includeStandings": false }`,
749
758
  inputSchema: {
750
759
  type: 'object',
751
760
  additionalProperties: false,
@@ -757,7 +766,10 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
757
766
  game: gameSchema({ allowAll: false, required: true }),
758
767
  eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Required unless q is given. Example: "ufc-300".', 'ufc-300'),
759
768
  q: stringSchema('Free-text event name when id/slug unknown. Example: "UFC 300". Resolves within this tool — still prefer resolve_entity when disambiguating many hits.', 'UFC 300'),
760
- includeBouts: boolSchema('Include bout/match list (default true).', true),
769
+ // Named for the general case (a card, a bracket, a match list), not
770
+ // combat-sport vocabulary — "bout" has no meaning for a tennis draw or a
771
+ // CS2 event hub, and this same parameter gates both.
772
+ includeMatches: boolSchema('Include the bout/match list, or the tennis draw bracket (default true).', true),
761
773
  includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false). ' +
762
774
  'For UFC this also joins divisional rank and movement onto each bout corner as team.rank / team.rankMovement — ' +
763
775
  'bout rows themselves carry no rank. Costs one extra upstream call.', false),
@@ -785,7 +797,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
785
797
  const game = gameParse.game;
786
798
  let eventKey = typeof args.eventIdOrSlug === 'string' ? args.eventIdOrSlug.trim() : '';
787
799
  const q = typeof args.q === 'string' ? args.q.trim() : '';
788
- const includeBouts = args.includeBouts !== false;
800
+ const includeBouts = args.includeMatches !== false;
789
801
  const includeStandings = args.includeStandings === true;
790
802
  const limit = clampInt(args.limit, 20, 1, 50);
791
803
  if (!eventKey && !q) {
@@ -834,6 +846,10 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
834
846
  };
835
847
  let bouts = [];
836
848
  let standingsSnippet = null;
849
+ // Tennis only: the draw bracket grouped by round, each round carrying the
850
+ // upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
851
+ // the tournament tree rather than one flat, order-dependent match list.
852
+ let drawRounds = null;
837
853
  if (game === 'ufc') {
838
854
  const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
839
855
  upstreamCalls += 1;
@@ -1246,16 +1262,29 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
1246
1262
  rateLimit = { ...rateLimit, ...draw.headers };
1247
1263
  if (draw.ok) {
1248
1264
  const dd = asRecord(unwrapPayload(draw.data)) ?? {};
1249
- const rounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1265
+ const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1250
1266
  const flat = [];
1251
- for (const rr of rounds) {
1267
+ drawRounds = rawRounds.map((rr) => {
1252
1268
  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 : [])) {
1269
+ const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
1270
+ const roundName = pickString(rec.round_name, rec.name, rec.round) ?? roundCode;
1271
+ const matches = (Array.isArray(rec.matches) ? rec.matches : []).map((mm) => {
1255
1272
  const mr = asRecord(mm) ?? {};
1256
- flat.push({ ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' });
1257
- }
1258
- }
1273
+ const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
1274
+ flat.push(row);
1275
+ // normalizeMatch gives named, id'd sides (player1/player2 once
1276
+ // presented); the raw score string and bracket-progression
1277
+ // fields are the only place the actual set score and next slot
1278
+ // live, so carry them alongside rather than dropping them.
1279
+ return presentSides({
1280
+ ...normalizeMatch('tennis', row),
1281
+ score: pickString(mr.score) ?? null,
1282
+ matchNum: typeof mr.match_num === 'number' ? mr.match_num : null,
1283
+ nextMatchId: pickString(mr.next_match_id) ?? null,
1284
+ }, 'tennis');
1285
+ });
1286
+ return { roundCode, roundName, matches };
1287
+ });
1259
1288
  bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
1260
1289
  if (!bouts.length)
1261
1290
  warnings.push('No draw yet: the tournament has no archived results');
@@ -1344,8 +1373,13 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "inc
1344
1373
  },
1345
1374
  data: {
1346
1375
  event,
1347
- bouts: includeBouts ? bouts : [],
1376
+ bouts: includeBouts ? bouts.map((b) => presentSides(b, game)) : [],
1348
1377
  boutCount: includeBouts ? bouts.length : 0,
1378
+ // Tennis only: the same matches grouped by round, with round codes
1379
+ // (Q1..R128, QF, SF, F) and each match's score/bracket progression —
1380
+ // this is what makes a bracket renderable; `bouts` above is the flat
1381
+ // list every other game already gets.
1382
+ ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds } : null } : {}),
1349
1383
  standings: includeStandings ? standingsSnippet : null,
1350
1384
  nextSteps: [
1351
1385
  'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
5
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
- import { normalizeMatch } from './normalize.js';
6
+ import { normalizeMatch, presentSides } from './normalize.js';
7
7
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
8
  const LIVE_PATHS = {
9
9
  lol: '/lol/live',
@@ -292,7 +292,9 @@ Example: { "game": "all", "limitPerGame": 10 }`,
292
292
  label: m.label,
293
293
  startTime: m.startTime,
294
294
  }))
295
- : normalized;
295
+ // Tennis has players, not teams: rename team1/team2 -> player1/player2
296
+ // at this boundary only. Every other game is untouched.
297
+ : normalized.map((m) => presentSides(m, m.game));
296
298
  if (game === 'ufc') {
297
299
  const health = asRecord(meta?.health) ?? asRecord(payload?.health);
298
300
  const events = Array.isArray(payload?.events) ? payload.events : [];
@@ -759,7 +761,9 @@ Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
759
761
  nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
760
762
  prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
761
763
  },
762
- data: { items },
764
+ // Tennis has players, not teams: rename team1/team2 -> player1/player2
765
+ // at this boundary only. Every other game is untouched.
766
+ data: { items: items.map((m) => presentSides(m, game)) },
763
767
  });
764
768
  },
765
769
  };
@@ -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, normalizeUfcMethod } from './normalize.js';
6
+ import { normalizeMatch, normalizeUfcMethod, presentSides } 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 });
@@ -61,7 +61,7 @@ function matchCore(game, matchId, raw) {
61
61
  const scoreline = m.team1 || m.team2
62
62
  ? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
63
63
  : null;
64
- return {
64
+ const core = {
65
65
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
66
66
  game,
67
67
  status: m.status,
@@ -78,10 +78,17 @@ function matchCore(game, matchId, raw) {
78
78
  team2: m.team2,
79
79
  event: m.event,
80
80
  league: m.league,
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,
81
+ // method/methodRaw/weightClass/referee are combat-sport vocabulary. They
82
+ // stayed on every match_summary/match_details card regardless of game, so
83
+ // a tennis card carried referee:null and weightClass:null foreign
84
+ // fields, not merely empty ones. UFC keeps them; every other game omits
85
+ // the keys entirely rather than shipping them as null.
86
+ ...(game === 'ufc'
87
+ ? {
88
+ method: normalizeUfcMethod(pickString(r.method, r.resultMethod)) ?? null,
89
+ methodRaw: pickString(r.method, r.resultMethod) ?? null,
90
+ }
91
+ : {}),
85
92
  winner: (() => {
86
93
  // Never name a winner while play is in progress: the score fallback
87
94
  // below reads "who is ahead", which mid-match is a lead, not a result.
@@ -109,33 +116,37 @@ function matchCore(game, matchId, raw) {
109
116
  return sideLabel(s1 > s2 ? m.team1 : m.team2);
110
117
  return null;
111
118
  })(),
112
- weightClass: pickString(r.weightClass, r.division) ?? null,
113
- referee: (() => {
114
- const nested = asRecord(r.referee);
115
- if (nested) {
116
- const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
117
- const name = pickString(nested.name, joined || undefined) ?? null;
118
- const id = pickString(nested.id, nested.refereeId) ?? null;
119
- if (name || id) {
119
+ ...(game === 'ufc'
120
+ ? {
121
+ weightClass: pickString(r.weightClass, r.division) ?? null,
122
+ referee: (() => {
123
+ const nested = asRecord(r.referee);
124
+ if (nested) {
125
+ const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
126
+ const name = pickString(nested.name, joined || undefined) ?? null;
127
+ const id = pickString(nested.id, nested.refereeId) ?? null;
128
+ if (name || id) {
129
+ return {
130
+ id,
131
+ name,
132
+ firstName: pickString(nested.firstName) ?? null,
133
+ lastName: pickString(nested.lastName) ?? null,
134
+ };
135
+ }
136
+ }
137
+ const name = pickString(r.refereeName) ?? null;
138
+ const id = pickString(r.refereeId) ?? null;
139
+ if (!name && !id)
140
+ return null;
120
141
  return {
121
142
  id,
122
143
  name,
123
- firstName: pickString(nested.firstName) ?? null,
124
- lastName: pickString(nested.lastName) ?? null,
144
+ firstName: pickString(r.refereeFirstName) ?? null,
145
+ lastName: pickString(r.refereeLastName) ?? null,
125
146
  };
126
- }
147
+ })(),
127
148
  }
128
- const name = pickString(r.refereeName) ?? null;
129
- const id = pickString(r.refereeId) ?? null;
130
- if (!name && !id)
131
- return null;
132
- return {
133
- id,
134
- name,
135
- firstName: pickString(r.refereeFirstName) ?? null,
136
- lastName: pickString(r.refereeLastName) ?? null,
137
- };
138
- })(),
149
+ : {}),
139
150
  rawStatus: pickString(r.status, r.state) ?? null,
140
151
  // Point-level live state (tennis): current game score like "40-AD", which
141
152
  // side is serving, and the set in progress. Absent for other games.
@@ -150,6 +161,17 @@ function matchCore(game, matchId, raw) {
150
161
  }
151
162
  : {}),
152
163
  };
164
+ return core;
165
+ }
166
+ /**
167
+ * Rename team1/team2 -> player1/player2 (top level and the nested score
168
+ * sub-object) for tennis only, at the point a matchCore object leaves this
169
+ * module. Kept separate from matchCore itself so every internal reader
170
+ * (scoreline strings, winner lookup, sideLabel) keeps working against the
171
+ * stable team1/team2 shape; only the client-facing copy is renamed.
172
+ */
173
+ function toClientMatch(core, game) {
174
+ return presentSides({ ...core, score: presentSides(core.score, game) }, game);
153
175
  }
154
176
  function primaryPath(game, matchId) {
155
177
  switch (game) {
@@ -338,6 +360,36 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
338
360
  }
339
361
  // games/maps
340
362
  enrich.push((async () => {
363
+ // Tennis has no per-set endpoint of its own; the set-by-set score is
364
+ // already sitting in `primary` (the same payload matchCore built the
365
+ // scoreline from). Pulling it out here means match_summary keeps the
366
+ // set breakdown instead of shipping gamesOrMaps: [] next to a fully
367
+ // populated `sets` array one field over.
368
+ if (game === 'tennis') {
369
+ const payload = asRecord(asRecord(primary.data)?.data) ?? asRecord(primary.data);
370
+ const sets = Array.isArray(payload?.sets) ? payload.sets : [];
371
+ gamesOrMaps = sets.map((s) => {
372
+ const sr = asRecord(s) ?? {};
373
+ // The live route (/tennis/matches/live/{id}) spells this set_num +
374
+ // score + is_completed; the archive route (/tennis/matches/{id})
375
+ // spells the same set set_number, with no per-set score string
376
+ // (the match-level `score` already holds "6-4, 6-4, 2-1") and no
377
+ // completion flag. Read both spellings rather than nulling out
378
+ // real data because match_summary happened to resolve the other
379
+ // route this time.
380
+ const p1g = typeof sr.player1_games === 'number' ? sr.player1_games : null;
381
+ const p2g = typeof sr.player2_games === 'number' ? sr.player2_games : null;
382
+ return {
383
+ setNumber: typeof sr.set_num === 'number' ? sr.set_num : typeof sr.set_number === 'number' ? sr.set_number : null,
384
+ score: pickString(sr.score) ?? (p1g != null && p2g != null ? `${p1g}-${p2g}` : null),
385
+ player1Games: p1g,
386
+ player2Games: p2g,
387
+ tiebreak: sr.tiebreak ?? null,
388
+ completed: sr.is_completed === true,
389
+ };
390
+ });
391
+ return;
392
+ }
341
393
  let path = null;
342
394
  if (game === 'lol')
343
395
  path = `/lol/matches/${encodeURIComponent(matchId)}/games`;
@@ -364,11 +416,11 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
364
416
  }
365
417
  })());
366
418
  await Promise.all(enrich);
367
- const scoreline = {
419
+ const scoreline = presentSides({
368
420
  display: `${match.team1?.name ?? '?'} ${match.score.team1 ?? '-'} : ${match.score.team2 ?? '-'} ${match.team2?.name ?? '?'}`,
369
421
  team1: match.score.team1,
370
422
  team2: match.score.team2,
371
- };
423
+ }, game);
372
424
  if (view === 'summary' && playerPerformances.length > 10) {
373
425
  playerPerformances = playerPerformances.slice(0, 10);
374
426
  }
@@ -382,7 +434,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
382
434
  partial: partial.length ? partial : undefined,
383
435
  entities: { games: [game], ids: { matchId: match.matchId } },
384
436
  data: {
385
- match,
437
+ match: toClientMatch(match, game),
386
438
  scoreline,
387
439
  keyEvents,
388
440
  playerPerformances,
@@ -588,9 +640,25 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
588
640
  // The header is the identity of the response, not an optional section.
589
641
  wanted.add('base');
590
642
  if (wanted.has('base')) {
591
- const res = await getSection(ctx, primaryPath(game, matchId));
643
+ let res = await getSection(ctx, primaryPath(game, matchId));
592
644
  upstreamCalls += 1;
593
645
  rateLimit = res.headers;
646
+ // Same fallback match_summary already has: the live board hands out
647
+ // s365_* ids that only resolve at /matches/live/{id} until the match
648
+ // finishes and archives. Without this, a matchId taken straight off
649
+ // live_matches 404s here even though match_summary resolves it fine.
650
+ if (!res.ok && game === 'tennis' && res.status === 404) {
651
+ res = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
652
+ upstreamCalls += 1;
653
+ rateLimit = { ...rateLimit, ...res.headers };
654
+ const gid = matchId.match(/^s365_(\d+)$/)?.[1];
655
+ if (!res.ok && gid) {
656
+ const year = new Date().getUTCFullYear();
657
+ res = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`s365_${year}_${gid}`)}`);
658
+ upstreamCalls += 1;
659
+ rateLimit = { ...rateLimit, ...res.headers };
660
+ }
661
+ }
594
662
  if (!res.ok) {
595
663
  return errorEnvelope({
596
664
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -604,7 +672,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
604
672
  httpStatus: res.status,
605
673
  });
606
674
  }
607
- sections.base = matchCore(game, matchId, res.data);
675
+ sections.base = toClientMatch(matchCore(game, matchId, res.data), game);
608
676
  }
609
677
  const tasks = [];
610
678
  const load = (section, path, query) => {
@@ -139,11 +139,12 @@ const TOOL_CATALOG = [
139
139
  {
140
140
  name: 'event_card',
141
141
  outcome: 'Event / fight-night card: identity + bout list, card-ordered (main event first), ' +
142
- 'each corner carrying photos, record, nickname, country and weight class (+ optional standings)',
142
+ 'each corner carrying photos, record, nickname, country and weight class (+ optional standings). ' +
143
+ 'For tennis, pass a tournament id/slug to get the draw bracket, grouped by round (Q1..R128, QF, SF, F).',
143
144
  parallelSafe: true,
144
145
  games: [...PRIMARY_GAMES],
145
146
  jobs: ['event_card', 'schedule', 'media', 'app_scaffold'],
146
- exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
147
+ exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeMatches: true },
147
148
  preferOver: [
148
149
  'call_api /ufc/events + bout expansion',
149
150
  'N+1 match_summary for card list',
@@ -208,7 +209,7 @@ const RECIPES = [
208
209
  id: 'ufc_fight_night_card',
209
210
  steps: [
210
211
  'resolve_entity { game: "ufc", type: "event", q } OR pass known eventIdOrSlug',
211
- 'event_card { game: "ufc", eventIdOrSlug, includeBouts: true }',
212
+ 'event_card { game: "ufc", eventIdOrSlug, includeMatches: true }',
212
213
  'optional match_preview for featured bout; standings { game: "ufc", scope: "division" }',
213
214
  ],
214
215
  },
@@ -28,6 +28,22 @@ function deriveProxiedImageUrl(rawUrl, game) {
28
28
  return null;
29
29
  return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
30
30
  }
31
+ /**
32
+ * Tennis has players, not teams. The cross-game NormalizedMatch type keeps
33
+ * team1/team2 internally for every game so the rest of this module stays
34
+ * uniform (nestedSide, sidesMatch, sortByCardOrder, …); this renames those two
35
+ * keys — and, when present, a nested score sub-object shaped the same way —
36
+ * at the JSON boundary for tennis only. Every other game passes through
37
+ * unchanged.
38
+ */
39
+ export function presentSides(obj, game) {
40
+ if (game !== 'tennis' || !obj || typeof obj !== 'object')
41
+ return obj;
42
+ if (!('team1' in obj) && !('team2' in obj))
43
+ return obj;
44
+ const { team1, team2, ...rest } = obj;
45
+ return { ...rest, player1: team1, player2: team2 };
46
+ }
31
47
  function sideFrom(name, id, slug, score, extra) {
32
48
  if (!name && !id && !slug)
33
49
  return null;
@@ -456,6 +472,29 @@ export function normalizeMatch(game, row, forcedStatus) {
456
472
  const resultRound = numOrNull(r.resultRound);
457
473
  const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
458
474
  const isCancelled = r.isCancelled === true;
475
+ // Tennis live/detail rows carry a top-level sets[] (set_num/player*_games/
476
+ // tiebreak/is_completed) plus game_score/server/current_set. A live board
477
+ // that only reports "score: 1" cannot tell 6-0 6-0 from a 7-6 in progress;
478
+ // carry the real breakdown through instead of collapsing it to an integer.
479
+ let tennisSets;
480
+ let tennisGameScore = null;
481
+ let tennisServer = null;
482
+ let tennisCurrentSet = null;
483
+ if (game === 'tennis' && Array.isArray(r.sets) && r.sets.length) {
484
+ tennisSets = r.sets.map((s) => {
485
+ const sr = asRecord(s) ?? {};
486
+ return {
487
+ setNumber: numOrNull(sr.set_num),
488
+ player1Games: numOrNull(sr.player1_games),
489
+ player2Games: numOrNull(sr.player2_games),
490
+ tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
491
+ completed: sr.is_completed === true,
492
+ };
493
+ });
494
+ tennisGameScore = pickString(r.game_score) ?? null;
495
+ tennisServer = typeof r.server === 'number' ? r.server : null;
496
+ tennisCurrentSet = typeof r.current_set === 'number' ? r.current_set : null;
497
+ }
459
498
  return {
460
499
  game,
461
500
  matchId,
@@ -466,7 +505,9 @@ export function normalizeMatch(game, row, forcedStatus) {
466
505
  team2,
467
506
  event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
468
507
  league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
469
- ...(weightOrClass ? { weightClass: weightOrClass } : {}),
508
+ // weightClass is UFC vocabulary; other games never set it, but never lie
509
+ // it into existence from a tennis/LoL row that merely shares a field name.
510
+ ...(weightOrClass && game === 'ufc' ? { weightClass: weightOrClass } : {}),
470
511
  ...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
471
512
  ...(hasCard
472
513
  ? {
@@ -481,16 +522,22 @@ export function normalizeMatch(game, row, forcedStatus) {
481
522
  ...(hasResultDetail
482
523
  ? {
483
524
  result: {
484
- method: method ?? null,
525
+ // method/referee are combat-sport vocabulary with no tennis
526
+ // meaning; a tennis history row otherwise picks up a
527
+ // result.method:null / result.referee:null pair from this same
528
+ // object shape, which reads as "render a fight" to an agent.
529
+ ...(game === 'ufc' ? { method: method ?? null, referee: referee ?? null } : {}),
485
530
  methodRaw: methodRaw ?? null,
486
531
  methodDetails: methodDetails ?? null,
487
532
  round: resultRound,
488
533
  time: resultTime ?? null,
489
- referee: referee ?? null,
490
534
  winnerSlug: winnerSlug ?? null,
491
535
  },
492
536
  }
493
537
  : {}),
538
+ ...(tennisSets
539
+ ? { sets: tennisSets, gameScore: tennisGameScore, server: tennisServer, currentSet: tennisCurrentSet }
540
+ : {}),
494
541
  ...(isCancelled
495
542
  ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
496
543
  : {}),
@@ -584,6 +584,15 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
584
584
  // while reporting total=1429 and hasMore=true. The UFC paths below fetch
585
585
  // page 1 deliberately and re-rank in memory, so they page here instead.
586
586
  let upstreamPaged = true;
587
+ // Set for a game whose dedicated search endpoint already returns results
588
+ // in the right order (e.g. tennis: /tennis/players/search ranks Carlos
589
+ // Alcaraz first). The generic re-rank below fuzzy-scores every candidate
590
+ // against the query string with no notion of who is actually ranked, so
591
+ // "alcaraz" tied four players at nearly the same score and resorted the
592
+ // world #3 to fourth — behind three players the upstream had already
593
+ // correctly placed below him. Preserving push order keeps the upstream's
594
+ // answer instead of a worse one computed from less information.
595
+ let preserveUpstreamOrder = false;
587
596
  const pageQuery = (extra = {}) => ({
588
597
  limit,
589
598
  offset,
@@ -686,6 +695,11 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
686
695
  const envelope = asRecord(res.data) ?? {};
687
696
  const data = asRecord(envelope.data) ?? envelope;
688
697
  items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'player', game)));
698
+ // /tennis/players/search already ranks the best-known match first
699
+ // (it has current_rank to break ties this tool's fuzzy scorer
700
+ // cannot see); do not let the generic re-sort below undo that.
701
+ if (q)
702
+ preserveUpstreamOrder = true;
689
703
  }
690
704
  }
691
705
  if (type === 'tournament' || type === 'event' || type === 'any') {
@@ -809,7 +823,7 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
809
823
  seen.add(key);
810
824
  return true;
811
825
  });
812
- if (q) {
826
+ if (q && !preserveUpstreamOrder) {
813
827
  items.sort((a, b) => {
814
828
  const sa = typeof a.meta?.score === 'number' ? a.meta.score : rankScore(q, a.name, a.id, a.slug, a.meta?.nickname);
815
829
  const sb = typeof b.meta?.score === 'number' ? b.meta.score : rankScore(q, b.name, b.id, b.slug, b.meta?.nickname);
@@ -4,7 +4,7 @@
4
4
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
5
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
6
  import { normalizeMatch } from './normalize.js';
7
- import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
7
+ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
8
  function teamIdentity(game, raw, idHint, slugHint) {
9
9
  const r = asRecord(raw) ?? {};
10
10
  const nested = asRecord(r.data) ?? r;
@@ -46,7 +46,15 @@ Example: { "game": "lol", "slug": "t1", "recentLimit": 10 }`,
46
46
  additionalProperties: false,
47
47
  required: ['game'],
48
48
  properties: {
49
- game: gameSchema({ allowAll: false, required: true }),
49
+ // Tennis is deliberately absent: singles has players, not team rosters,
50
+ // and advertising it here only to throw NOT_IMPLEMENTED on every call is
51
+ // a trap — the schema told the caller the request was valid. player_profile
52
+ // and standings{game:"tennis"} cover the same ground honestly.
53
+ game: {
54
+ type: 'string',
55
+ enum: PRIMARY_GAMES.filter((g) => g !== 'tennis'),
56
+ description: 'Game title: lol | cs2 | dota2 | cod | ufc. Tennis has players, not team rosters — use player_profile.',
57
+ },
50
58
  teamId: stringSchema('Team id. Prefer for cs2. Pass teamId and/or slug (at least one required). Either works for dota2.'),
51
59
  slug: stringSchema('Team/org slug. Prefer for lol and cod orgs. Pass teamId and/or slug (at least one required). Example: "t1".', 't1'),
52
60
  recentLimit: limitSchema({ default: 10, max: 25 }),
@@ -903,6 +911,18 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
903
911
  }
904
912
  const envelope = asRecord(res.data) ?? {};
905
913
  const h2h = asRecord(envelope.data) ?? envelope;
914
+ // The id-shaped shortcut above never resolves a name — sideA:"atp_207989"
915
+ // came in already looking like an id, so resolveTennisSide handed back
916
+ // {id: side, name: side} without ever asking who that id belongs to. The
917
+ // /tennis/h2h payload already states both names one level down (its own
918
+ // player1/player2, keyed by the same player1_id/player2_id this request
919
+ // just sent), so read from there rather than trusting the resolver's
920
+ // name at all: it is authoritative and always available once the H2H
921
+ // call itself succeeded.
922
+ const h2hP1 = asRecord(h2h.player1);
923
+ const h2hP2 = asRecord(h2h.player2);
924
+ const sideAName = pickString(h2hP1?.name) ?? tA.name;
925
+ const sideBName = pickString(h2hP2?.name) ?? tB.name;
906
926
  return successEnvelope({
907
927
  source: 'head_to_head',
908
928
  game,
@@ -911,8 +931,8 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
911
931
  upstreamCalls,
912
932
  rateLimit,
913
933
  data: {
914
- sideA: { idOrSlug: tA.id, name: tA.name },
915
- sideB: { idOrSlug: tB.id, name: tB.name },
934
+ sideA: { idOrSlug: tA.id, name: sideAName },
935
+ sideB: { idOrSlug: tB.id, name: sideBName },
916
936
  h2h,
917
937
  notes: ['Record and meetings come from the first-class /tennis/h2h endpoint'],
918
938
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
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": {