cito-mcp 0.5.4 → 0.5.6

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/README.md CHANGED
@@ -550,6 +550,19 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
550
550
 
551
551
  ## Changelog (summary)
552
552
 
553
+ ### 0.5.6
554
+
555
+ - **Tennis `event_card` draw is a real bracket** (customer audit 2026-11-03). `draw.complete` is true only when every main round to the Final is played (it said true beside `missingRounds` F/SF/QF); every main round is listed with `status` (`completed` / `in_progress` / `not_started` / `incomplete_archive`), `expected` (byes excluded: a 56 draw's R64 holds 24), `byes`, `walkovers`, and `pending` (scheduled / live matches, never presented as results). `drawSize` is the real field (Paris 56, `drawSizeSource`, `bracketSize`, `byes`); `matchesExpected` counts the main draw and is compared with `mainDrawMatchesReturned` (qualifying is reported apart). Each bout carries `winnerId` / `loserId` / `outcome` (a "6-3 6-7 RET" retirement says who won), `startTime`, `nextMatchId` (the bracket edge) and `bracketPosition`; rounds report `included` / `omitted` when `drawMaxMatches` trims them.
556
+ - **Tennis `head_to_head` honours `limit`**: it is sent to `/tennis/h2h`, which pages the meetings while every tally still covers all of them (`meetingsReturned` / `meetingsTotal`; the H2H `level_breakdown` now always sums to `total_matches`).
557
+ - Other tennis audit fixes in the same release: live stats as structured numerator / denominator / pct, `isSimulated` / `liveFeed` on live rows, `setNumber` from `set_number`, and the schedule / rankings / player readers updated for the API changes of 2026-11-03.
558
+
559
+ ### 0.5.5
560
+
561
+ - **`cs2_veto_sequence` returns the veto.** It called the match head-to-head route and answered with the two teams' head-to-head record (`cs2_team_h2h`) instead of the veto (0.5.1 to 0.5.4). It now calls `/cs2/matches/{matchId}/veto`: `vetoOrder` (every ban, pick and the decider, in order), `vetoStatus` (`complete`, `pending`, `forfeit_no_veto`, `unavailable`), `bestOf` and the maps.
562
+ - **`cs2_opening_duels` with `playerId` works.** It called a route the API does not have (every call was a 404). A player's opening duels now come from the career totals: `openingKills` / `openingDeaths` (null when fewer than 90% of the player's maps record them), the recorded sums, coverage and success rate.
563
+ - **`cs2_team_map_stats` takes `opponentId`** for a map-by-map comparison of two teams (`/cs2/teams/{team}/map-pool/vs/{opponent}`: win rates, round difference, pick/ban/decider rates, their meetings per map, edge). Map rows carry `inPool` / `poolStatus`.
564
+ - `cs2_round_economy` points to `/cs2/maps/{mapId}/economy` for per-player money each round.
565
+
553
566
  ### 0.5.4
554
567
 
555
568
  - **Apex Legends through `call_api`.** `/apex/...` paths (ALGS events, standings, scores) were refused with `PATH_NOT_ALLOWED` although they are a live REST product. They are allowed now and listed in `list_capabilities`.
package/dist/tools/cs2.js CHANGED
@@ -59,7 +59,8 @@ export const cs2LiveScoreboard = {
59
59
  };
60
60
  export const cs2RoundEconomy = {
61
61
  name: 'cs2_round_economy',
62
- description: 'Round-by-round tactical economy for a CS2 map. Classifies every round buy type (Full Buy, Force Buy, Semi-Eco, Full Eco), team equipment spend, freeze-time equipment values, and loss bonus counter.',
62
+ description: 'Round-by-round tactical economy for a CS2 map. Classifies every round buy type (Full Buy, Force Buy, Semi-Eco, Full Eco), team equipment spend, freeze-time equipment values, and loss bonus counter. '
63
+ + 'For money per PLAYER each round (startMoney, moneySpent, moneyLeft, equipmentValue, buyType, purchases) use call_api GET /cs2/maps/{mapId}/economy (by=player|team|round; paid plans).',
63
64
  inputSchema: {
64
65
  type: 'object',
65
66
  properties: {
@@ -123,9 +124,9 @@ export const cs2OpeningDuels = {
123
124
  const rawPlayer = typeof args.playerId === 'string' ? args.playerId.trim() : '';
124
125
  const limit = clampInt(args.limit, 20, 1, 100);
125
126
  const path = rawPlayer
126
- ? `/cs2/players/${encodeURIComponent(rawPlayer)}/opening-duels`
127
+ ? `/cs2/players/${encodeURIComponent(rawPlayer)}/career`
127
128
  : `/cs2/stats/opening-duels`;
128
- const res = await fetchJson(ctx, path, { query: { limit } });
129
+ const res = await fetchJson(ctx, path, rawPlayer ? {} : { query: { limit } });
129
130
  if (!res.ok) {
130
131
  return errorEnvelope({
131
132
  code: mapHttpToCode(res.status),
@@ -137,8 +138,29 @@ export const cs2OpeningDuels = {
137
138
  });
138
139
  }
139
140
  const payload = unwrap(res.data);
141
+ let data = payload;
142
+ if (rawPlayer) {
143
+ const p = asRecord(payload) ?? {};
144
+ const t = asRecord(p.totals) ?? {};
145
+ const fk = typeof t.openingKillsRecorded === 'number' ? t.openingKillsRecorded : null;
146
+ const fd = typeof t.openingDeathsRecorded === 'number' ? t.openingDeathsRecorded : null;
147
+ data = {
148
+ entityType: 'cs2_player_opening_duels',
149
+ playerId: p.playerId ?? null,
150
+ playerNickname: p.playerNickname ?? null,
151
+ maps: t.maps ?? null,
152
+ openingKills: t.openingKills ?? null,
153
+ openingDeaths: t.openingDeaths ?? null,
154
+ openingKillsRecorded: fk,
155
+ openingDeathsRecorded: fd,
156
+ openingDuelMaps: t.openingDuelMaps ?? null,
157
+ openingDuelCoverage: t.openingDuelCoverage ?? null,
158
+ openingDuelSuccessRate: fk !== null && fd !== null && fk + fd > 0 ? Math.round((fk / (fk + fd)) * 10000) / 10000 : null,
159
+ source: `/cs2/players/${rawPlayer}/career`,
160
+ };
161
+ }
140
162
  return successEnvelope({
141
- data: payload,
163
+ data,
142
164
  game: 'cs2',
143
165
  source: 'cs2_opening_duels',
144
166
  requestId: reqId,
@@ -188,16 +210,18 @@ export const cs2Clutches = {
188
210
  };
189
211
  export const cs2TeamMapStats = {
190
212
  name: 'cs2_team_map_stats',
191
- description: 'Team win rates, round win rates, and CT/T side win splits across the competitive map pool (Mirage, Inferno, Nuke, Dust2, Ancient, Anubis, Vertigo) over the last 30 or 90 days.',
213
+ description: 'Team win rates, round win rates, and CT/T side win splits per map over the last N days. Each map carries inPool / poolStatus (current Active Duty pool) and removedAt. '
214
+ + 'Pass opponentId to compare two teams map by map instead (GET /cs2/teams/{team}/map-pool/vs/{opponent}): win rates, round difference, pick/ban/decider rates, their meetings on each map and an edge per map.',
192
215
  inputSchema: {
193
216
  type: 'object',
194
217
  properties: {
195
218
  teamId: stringSchema('Team ID or slug (e.g. "cs2-team-4608" or "natus-vincere"). Required.', 'cs2-team-4608'),
196
219
  days: {
197
220
  type: 'integer',
198
- description: 'Timeframe in days: 30, 90, 180, or 365 (default 90).',
221
+ description: 'Timeframe in days: 30, 90, 180, or 365 (default 90; with opponentId the API default is 180).',
199
222
  default: 90,
200
223
  },
224
+ opponentId: stringSchema('Optional opponent team ID or slug (e.g. "mouz"): returns the map-pool head-to-head of the two teams.', 'mouz'),
201
225
  },
202
226
  required: ['teamId'],
203
227
  additionalProperties: false,
@@ -215,9 +239,12 @@ export const cs2TeamMapStats = {
215
239
  requestId: reqId,
216
240
  });
217
241
  }
218
- const days = typeof args.days === 'number' ? args.days : 90;
219
- const path = `/cs2/teams/${encodeURIComponent(rawTeam)}/map-stats`;
220
- const res = await fetchJson(ctx, path, { query: { days } });
242
+ const rawOpponent = typeof args.opponentId === 'string' ? args.opponentId.trim() : '';
243
+ const days = typeof args.days === 'number' ? args.days : rawOpponent ? undefined : 90;
244
+ const path = rawOpponent
245
+ ? `/cs2/teams/${encodeURIComponent(rawTeam)}/map-pool/vs/${encodeURIComponent(rawOpponent)}`
246
+ : `/cs2/teams/${encodeURIComponent(rawTeam)}/map-stats`;
247
+ const res = await fetchJson(ctx, path, days === undefined ? {} : { query: { days } });
221
248
  if (!res.ok) {
222
249
  return errorEnvelope({
223
250
  code: mapHttpToCode(res.status),
@@ -276,7 +303,8 @@ export const cs2UtilityLeaderboard = {
276
303
  };
277
304
  export const cs2VetoSequence = {
278
305
  name: 'cs2_veto_sequence',
279
- description: 'Map pick/ban veto sequence for a CS2 match. Shows which team banned which map, map picks, and decider map in chronological order.',
306
+ description: 'Map pick/ban veto sequence for a CS2 match (GET /cs2/matches/{matchId}/veto). Returns vetoOrder (each ban, pick and the decider in order, with the team), '
307
+ + 'vetoStatus (complete | pending | forfeit_no_veto | unavailable; vetoOrder is [] unless complete), bestOf and the maps.',
280
308
  inputSchema: {
281
309
  type: 'object',
282
310
  properties: {
@@ -298,8 +326,7 @@ export const cs2VetoSequence = {
298
326
  requestId: reqId,
299
327
  });
300
328
  }
301
- const cleanId = rawId.replace(/^cs2-match-/, '');
302
- const path = `/cs2/matches/${encodeURIComponent(cleanId)}/head-to-head`;
329
+ const path = `/cs2/matches/${encodeURIComponent(rawId)}/veto`;
303
330
  const res = await fetchJson(ctx, path);
304
331
  if (!res.ok) {
305
332
  return errorEnvelope({
@@ -312,10 +339,8 @@ export const cs2VetoSequence = {
312
339
  });
313
340
  }
314
341
  const payload = unwrap(res.data);
315
- const r = asRecord(payload);
316
- const veto = r?.veto ?? r?.vetoSequence ?? payload;
317
342
  return successEnvelope({
318
- data: veto,
343
+ data: payload,
319
344
  game: 'cs2',
320
345
  source: 'cs2_veto_sequence',
321
346
  requestId: reqId,
@@ -260,20 +260,20 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
260
260
  }
261
261
  export const matchPreview = {
262
262
  name: 'match_preview',
263
- description: `COMPOSITE pre-match briefing: sides, roster/form snippets, H2H stub, event context — for pick'ems, articles, and match-page before state.
264
-
265
- When to use:
266
- - Upcoming match deep link
267
- - "Who should I watch before this game?"
268
- - App scaffold for preview cards
269
-
270
- Prefer over: manually chaining team_profile ×2 + head_to_head + schedule.
271
- Prefer match_summary when match is completed; match_details for live in-game.
272
-
273
- Do not use when: user wants final score/recap of a finished match.
274
-
275
- Parallel-safe: yes. Upstream cost: 4–8.
276
- Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
263
+ description: `COMPOSITE pre-match briefing: sides, roster/form snippets, H2H stub, event context — for pick'ems, articles, and match-page before state.
264
+
265
+ When to use:
266
+ - Upcoming match deep link
267
+ - "Who should I watch before this game?"
268
+ - App scaffold for preview cards
269
+
270
+ Prefer over: manually chaining team_profile ×2 + head_to_head + schedule.
271
+ Prefer match_summary when match is completed; match_details for live in-game.
272
+
273
+ Do not use when: user wants final score/recap of a finished match.
274
+
275
+ Parallel-safe: yes. Upstream cost: 4–8.
276
+ Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
277
277
  Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
278
278
  inputSchema: {
279
279
  type: 'object',
@@ -707,31 +707,37 @@ async function resolveEventKey(ctx, game, q) {
707
707
  }
708
708
  export const eventCard = {
709
709
  name: 'event_card',
710
- description: `COMPOSITE event / fight-night card: identity, bout or match list, optional standings snippet.
711
-
712
- When to use:
713
- - UFC fight night / numbered event page ("UFC 300 card", "Fight Night")
714
- - CS2 event hub with match list
715
- - Tournament/event overview before match_preview drill-down
716
-
717
- Bouts come back in card order — main event first, then prelims, then early prelims.
718
- Each bout carries weightClass, titleBout, card placement, and (once fought) result
719
- { method, round, time, referee, winnerSlug }. Each corner carries images
720
- { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
721
- championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
722
- browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
723
-
724
- Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
725
-
726
- Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
727
-
728
- 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.
729
-
730
- Tennis: pass a tournament id/slug to get the draw bracket — rounds carry a round
731
- code (Q1..R128, QF, SF, F) with each match's players and score. Set is the
732
- primary tennis path here; there is no separate draw tool.
733
-
734
- Parallel-safe: yes. Upstream cost: 1–4.
710
+ description: `COMPOSITE event / fight-night card: identity, bout or match list, optional standings snippet.
711
+
712
+ When to use:
713
+ - UFC fight night / numbered event page ("UFC 300 card", "Fight Night")
714
+ - CS2 event hub with match list
715
+ - Tournament/event overview before match_preview drill-down
716
+
717
+ Bouts come back in card order — main event first, then prelims, then early prelims.
718
+ Each bout carries weightClass, titleBout, card placement, and (once fought) result
719
+ { method, round, time, referee, winnerSlug }. Each corner carries images
720
+ { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
721
+ championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
722
+ browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
723
+
724
+ Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
725
+
726
+ Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
727
+
728
+ 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.
729
+
730
+ Tennis: pass a tournament id/slug to get the draw bracket — every main round from
731
+ the first to the Final (status completed / in_progress / not_started), then
732
+ qualifying (Q1..Q3). Each result carries winnerId/loserId/outcome (retirements
733
+ included), startTime (null with startTimeUnavailableReason when the source has no
734
+ clock), nextMatchId (the bracket edge) and bracketPosition; scheduled and live
735
+ matches sit in rounds[].pending. draw carries drawSize (real field size, byes
736
+ excluded, e.g. 56), byes, matchesExpected (main draw) and complete (true only
737
+ when every main round to the Final is played). This is the primary tennis path
738
+ here; there is no separate draw tool.
739
+
740
+ Parallel-safe: yes. Upstream cost: 1–4.
735
741
  Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "includeStandings": false }`,
736
742
  inputSchema: {
737
743
  type: 'object',
@@ -1245,9 +1251,74 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1245
1251
  const dd = asRecord(unwrapPayload(draw.data)) ?? {};
1246
1252
  const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1247
1253
  const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
1254
+ const apiSummary = asRecord(dd.summary);
1255
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
1248
1256
  const flat = [];
1249
1257
  let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
1250
1258
  let omittedMatches = 0;
1259
+ const drawMatch = (mr, roundName) => {
1260
+ const p1 = asRecord(mr.player1);
1261
+ const p2 = asRecord(mr.player2);
1262
+ const winnerId = pickString(mr.winner_id) ??
1263
+ (p1?.is_winner === true ? pickString(p1?.id) : p2?.is_winner === true ? pickString(p2?.id) : undefined) ??
1264
+ null;
1265
+ const loserId = pickString(mr.loser_id) ??
1266
+ (winnerId && winnerId === pickString(p1?.id) ? pickString(p2?.id) : winnerId ? pickString(p1?.id) : undefined) ??
1267
+ null;
1268
+ const row = {
1269
+ ...mr,
1270
+ id: mr.match_id,
1271
+ round: roundName,
1272
+ tournament_name: event.name,
1273
+ status: 'completed',
1274
+ startTime: pickString(mr.start_time) ?? null,
1275
+ };
1276
+ flat.push(row);
1277
+ return presentSides({
1278
+ ...normalizeMatch('tennis', row),
1279
+ startTime: pickString(mr.start_time) ?? null,
1280
+ score: pickString(mr.score) ?? null,
1281
+ matchNum: typeof mr.match_num === 'number' ? mr.match_num : null,
1282
+ winnerId,
1283
+ loserId,
1284
+ outcome: pickString(mr.outcome) ?? null,
1285
+ resultStatus: pickString(mr.status) ?? 'completed',
1286
+ matchDate: pickString(mr.match_date) ?? null,
1287
+ startTimeKnown: mr.start_time_known === true,
1288
+ ...(pickString(mr.start_time_unavailable_reason)
1289
+ ? { startTimeUnavailableReason: pickString(mr.start_time_unavailable_reason) }
1290
+ : {}),
1291
+ nextMatchId: pickString(mr.next_match_id) ?? null,
1292
+ ...(pickString(mr.next_match_source) ? { nextMatchSource: pickString(mr.next_match_source) } : {}),
1293
+ bracketPosition: num(mr.bracket_position),
1294
+ nextSlot: asRecord(mr.next_slot)
1295
+ ? {
1296
+ roundCode: pickString(asRecord(mr.next_slot)?.round_code) ?? null,
1297
+ position: num(asRecord(mr.next_slot)?.position),
1298
+ }
1299
+ : null,
1300
+ ...(pickString(mr.source_round) ? { sourceRound: pickString(mr.source_round) } : {}),
1301
+ }, 'tennis');
1302
+ };
1303
+ const pendingMatch = (pr) => {
1304
+ const side = (v) => {
1305
+ const s = asRecord(v) ?? {};
1306
+ return { id: pickString(s.id) ?? null, name: pickString(s.name) ?? null, seed: s.seed ?? null };
1307
+ };
1308
+ return {
1309
+ matchId: pickString(pr.match_id) ?? null,
1310
+ status: pickString(pr.status) ?? null,
1311
+ player1: side(pr.player1),
1312
+ player2: side(pr.player2),
1313
+ startTime: pickString(pr.start_time) ?? null,
1314
+ startTimeKnown: pr.start_time_known === true,
1315
+ liveScore: pickString(pr.live_score) ?? null,
1316
+ winnerId: null,
1317
+ ...(pickString(pr.source_winner_id) ? { sourceWinnerId: pickString(pr.source_winner_id) } : {}),
1318
+ nextMatchId: pickString(pr.next_match_id) ?? null,
1319
+ bracketPosition: num(pr.bracket_position),
1320
+ };
1321
+ };
1251
1322
  drawRounds = rawRounds.map((rr) => {
1252
1323
  const rec = asRecord(rr) ?? {};
1253
1324
  const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
@@ -1256,58 +1327,100 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1256
1327
  const kept = all.slice(0, Math.max(0, budget));
1257
1328
  omittedMatches += all.length - kept.length;
1258
1329
  budget -= kept.length;
1259
- const matches = kept.map((mm) => {
1260
- const mr = asRecord(mm) ?? {};
1261
- const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
1262
- flat.push(row);
1263
- return presentSides({
1264
- ...normalizeMatch('tennis', row),
1265
- score: pickString(mr.score) ?? null,
1266
- matchNum: typeof mr.match_num === 'number' ? mr.match_num : null,
1267
- nextMatchId: pickString(mr.next_match_id) ?? null,
1268
- }, 'tennis');
1269
- });
1330
+ const matches = kept.map((mm) => drawMatch(asRecord(mm) ?? {}, roundName));
1331
+ const pendingRaw = Array.isArray(rec.pending_matches) ? rec.pending_matches : [];
1332
+ const expected = num(rec.expected_matches) ?? expectedMatchesForRound(roundCode, drawSize);
1333
+ const apiStatus = pickString(rec.status) ?? null;
1270
1334
  return {
1271
1335
  roundCode,
1272
1336
  roundName,
1273
1337
  matches,
1338
+ pending: pendingRaw.map((p) => pendingMatch(asRecord(p) ?? {})),
1274
1339
  returned: all.length,
1275
- expected: expectedMatchesForRound(roundCode, drawSize),
1276
- complete: (() => {
1277
- const expected = expectedMatchesForRound(roundCode, drawSize);
1278
- return expected === null ? null : all.length >= expected;
1279
- })(),
1340
+ included: kept.length,
1341
+ omitted: all.length - kept.length,
1342
+ expected,
1343
+ byes: num(rec.byes),
1344
+ walkovers: num(rec.walkovers),
1345
+ status: apiStatus ?? (all.length === 0 ? 'not_started' : expected !== null && all.length >= expected ? 'completed' : 'in_progress'),
1346
+ complete: typeof rec.complete === 'boolean' ? rec.complete : expected === null ? null : all.length >= expected,
1347
+ isQualifying: rec.is_qualifying === true || /^Q\d?$/.test(roundCode ?? ''),
1280
1348
  };
1281
1349
  });
1282
- bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
1283
- if (!bouts.length)
1284
- warnings.push('No draw yet: the tournament has no archived results');
1285
- const expectedTotal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
1350
+ bouts = flat.slice(0, limit).map((row) => {
1351
+ const n = normalizeMatch('tennis', row);
1352
+ const r = row;
1353
+ const p1 = asRecord(r.player1);
1354
+ const p2 = asRecord(r.player2);
1355
+ return {
1356
+ ...n,
1357
+ startTime: pickString(r.start_time) ?? null,
1358
+ winnerId: pickString(r.winner_id) ?? (p1?.is_winner === true ? pickString(p1?.id) : p2?.is_winner === true ? pickString(p2?.id) : undefined) ?? null,
1359
+ loserId: pickString(r.loser_id) ?? null,
1360
+ outcome: pickString(r.outcome) ?? null,
1361
+ nextMatchId: pickString(r.next_match_id) ?? null,
1362
+ };
1363
+ });
1364
+ if (!bouts.length) {
1365
+ const anyPending = drawRounds.some((r) => r.pending.length > 0);
1366
+ warnings.push(anyPending
1367
+ ? 'No results yet: every match of this draw is still scheduled or in progress (see draw.rounds[].pending)'
1368
+ : 'No draw yet: the tournament has no archived results');
1369
+ }
1370
+ const mainRounds = drawRounds.filter((r) => !r.isQualifying);
1371
+ const expectedTotalLocal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
1286
1372
  const returnedTotal = drawRounds.reduce((sum, r) => sum + r.returned, 0);
1373
+ const mainReturned = mainRounds.reduce((sum, r) => sum + r.returned, 0);
1287
1374
  const shortRounds = drawRounds
1288
- .filter((r) => r.complete === false)
1375
+ .filter((r) => r.complete === false && r.status !== 'not_started')
1289
1376
  .map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
1290
1377
  const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
1291
- const missingRounds = ['F', 'SF', 'QF']
1292
- .filter((c) => !haveCodes.has(c))
1293
- .filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
1378
+ const missingRounds = ['F', 'SF', 'QF'].filter((c) => !haveCodes.has(c));
1379
+ const roundsNotStarted = mainRounds.filter((r) => r.status === 'not_started').map((r) => r.roundCode);
1380
+ const roundsInProgress = mainRounds.filter((r) => r.status === 'in_progress').map((r) => r.roundCode);
1381
+ const roundsIncompleteArchive = mainRounds
1382
+ .filter((r) => r.status === 'incomplete_archive')
1383
+ .map((r) => r.roundCode);
1384
+ const matchesExpected = num(apiSummary?.main_draw_matches_expected) ?? (expectedTotalLocal || null);
1385
+ const complete = typeof apiSummary?.complete === 'boolean'
1386
+ ? apiSummary.complete &&
1387
+ shortRounds.length === 0 &&
1388
+ missingRounds.length === 0 &&
1389
+ roundsNotStarted.length === 0
1390
+ : matchesExpected !== null &&
1391
+ mainReturned >= matchesExpected &&
1392
+ shortRounds.length === 0 &&
1393
+ missingRounds.length === 0;
1394
+ if (drawSize !== null && event.drawSize !== drawSize) {
1395
+ event = { ...event, drawSizeRecorded: event.drawSize ?? null, drawSize };
1396
+ }
1294
1397
  drawSummary = {
1295
1398
  drawSize,
1399
+ drawSizeSource: pickString(dd.draw_size_source) ?? null,
1400
+ bracketSize: num(dd.bracket_size),
1401
+ byes: num(dd.byes),
1296
1402
  roundCount: drawRounds.length,
1403
+ status: pickString(apiSummary?.status) ?? (complete ? 'completed' : returnedTotal ? 'in_progress' : 'not_started'),
1297
1404
  matchesReturned: returnedTotal,
1298
- matchesExpected: expectedTotal || null,
1299
- complete: expectedTotal > 0 ? returnedTotal >= expectedTotal : null,
1405
+ mainDrawMatchesReturned: mainReturned,
1406
+ qualifyingMatchesReturned: returnedTotal - mainReturned,
1407
+ matchesExpected,
1408
+ matchesPending: drawRounds.reduce((sum, r) => sum + r.pending.length, 0),
1409
+ complete,
1300
1410
  shortRounds,
1301
1411
  missingRounds,
1412
+ roundsNotStarted,
1413
+ roundsInProgress,
1414
+ roundsIncompleteArchive,
1302
1415
  totalMatchesInCard: flat.length,
1303
1416
  returnedInBouts: bouts.length,
1304
1417
  truncated: omittedMatches > 0,
1305
1418
  omittedMatches,
1306
1419
  maxMatches: drawMaxMatches,
1307
- note: 'matchesReturned counts what the archive holds for this tournament; matchesExpected counts a full draw of drawSize. shortRounds names every round that is short, and missingRounds names a round the bracket should contain but does not.',
1420
+ note: 'matchesExpected counts the MAIN draw (byes excluded) and is compared with mainDrawMatchesReturned; matchesReturned also counts qualifying. Per round: returned = archived results, included = how many of them this response carries (drawMaxMatches), pending = scheduled / in-progress matches. complete is true only when every main round to the Final is completed.',
1308
1421
  };
1309
1422
  if (shortRounds.length) {
1310
- warnings.push(`Draw is partial: ${shortRounds.join(', ')}. The archive does not hold every match of this tournament yet.`);
1423
+ warnings.push(`Draw is partial: ${shortRounds.join(', ')}. ${roundsInProgress.length ? 'The tournament is in progress.' : 'The archive does not hold every match of this tournament yet.'}`);
1311
1424
  }
1312
1425
  if (omittedMatches > 0) {
1313
1426
  warnings.push(`drawMaxMatches=${drawMaxMatches} dropped ${omittedMatches} early-round match(es) from draw.rounds; raise or omit it for the complete bracket.`);
@@ -139,6 +139,14 @@ function matchCore(game, matchId, raw) {
139
139
  },
140
140
  }
141
141
  : {}),
142
+ ...(game === 'tennis'
143
+ ? {
144
+ canonicalId: pickString(r.canonical_id, r.id, r.match_id) ?? null,
145
+ tournamentDisplayName: pickString(r.tournament_display_name, asRecord(r.tournament)?.display_name, r.tournament_name) ?? null,
146
+ resultAvailable: typeof r.result_available === 'boolean' ? r.result_available : Boolean(pickString(r.winner_id)),
147
+ resultUnavailableReason: pickString(r.result_unavailable_reason) ?? null,
148
+ }
149
+ : {}),
142
150
  };
143
151
  return core;
144
152
  }
@@ -163,18 +171,18 @@ function primaryPath(game, matchId) {
163
171
  }
164
172
  export const matchSummary = {
165
173
  name: 'match_summary',
166
- description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
167
-
168
- When to use:
169
- - Match recap / default match UI
170
- - After user selects a live or completed matchId
171
-
172
- Prefer over match_details for chat answers and default UIs.
173
- Prefer match_details for timelines, full map trees, live state, advanced packages.
174
-
175
- Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
176
-
177
- Parallel-safe: yes. Upstream cost: 2–5.
174
+ description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
175
+
176
+ When to use:
177
+ - Match recap / default match UI
178
+ - After user selects a live or completed matchId
179
+
180
+ Prefer over match_details for chat answers and default UIs.
181
+ Prefer match_details for timelines, full map trees, live state, advanced packages.
182
+
183
+ Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
184
+
185
+ Parallel-safe: yes. Upstream cost: 2–5.
178
186
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
179
187
  inputSchema: {
180
188
  type: 'object',
@@ -331,7 +339,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
331
339
  const p1g = typeof sr.player1_games === 'number' ? sr.player1_games : null;
332
340
  const p2g = typeof sr.player2_games === 'number' ? sr.player2_games : null;
333
341
  return {
334
- setNumber: typeof sr.set_num === 'number' ? sr.set_num : typeof sr.set_number === 'number' ? sr.set_number : null,
342
+ setNumber: typeof sr.set_number === 'number' ? sr.set_number : typeof sr.set_num === 'number' ? sr.set_num : null,
335
343
  score: pickString(sr.score) ?? (p1g != null && p2g != null ? `${p1g}-${p2g}` : null),
336
344
  player1Games: p1g,
337
345
  player2Games: p2g,
@@ -558,10 +566,27 @@ export function summarizeTennisMatchStats(data) {
558
566
  const stats = asRecord(payload.stats);
559
567
  const sets = Array.isArray(payload.sets) ? payload.sets : [];
560
568
  const toNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
569
+ const liveRows = Array.isArray(payload.stats) ? payload.stats.map((x) => asRecord(x) ?? {}) : null;
570
+ const liveStats = liveRows
571
+ ? liveRows.map((r) => ({
572
+ side: toNum(r.side),
573
+ key: pickString(r.key) ?? null,
574
+ name: pickString(r.name) ?? null,
575
+ category: pickString(r.category) ?? null,
576
+ kind: pickString(r.kind) ?? null,
577
+ numerator: toNum(r.numerator),
578
+ denominator: toNum(r.denominator),
579
+ pct: toNum(r.pct),
580
+ display: pickString(r.display) ?? null,
581
+ }))
582
+ : null;
561
583
  return {
562
584
  matchId: pickString(payload.match_id) ?? null,
563
585
  score: pickString(payload.score) ?? null,
564
- sidesKeyedBy: 'outcome',
586
+ sidesKeyedBy: liveStats ? 'side' : 'outcome',
587
+ ...(liveStats ? { liveStats } : {}),
588
+ statsAvailable: typeof payload.stats_available === 'boolean' ? payload.stats_available : null,
589
+ statsUnavailableCode: pickString(payload.stats_unavailable_code) ?? null,
565
590
  players: stats
566
591
  ? {
567
592
  winner: asRecord(stats.winner) ?? null,
@@ -571,7 +596,7 @@ export function summarizeTennisMatchStats(data) {
571
596
  sets: sets.map((s) => {
572
597
  const sr = asRecord(s) ?? {};
573
598
  return {
574
- setNumber: toNum(sr.set_num) ?? toNum(sr.set_number),
599
+ setNumber: toNum(sr.set_number) ?? toNum(sr.set_num),
575
600
  winnerGames: toNum(sr.winner_games),
576
601
  loserGames: toNum(sr.loser_games),
577
602
  player1Games: toNum(sr.player1_games),
@@ -586,23 +611,23 @@ export function summarizeTennisMatchStats(data) {
586
611
  }
587
612
  export const matchDetails = {
588
613
  name: 'match_details',
589
- description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
590
-
591
- When to use:
592
- - Analyst deep dive
593
- - Live in-game window (LoL/CS2/UFC)
594
- - Full demo list
595
-
596
- Prefer over match_summary only when summary is insufficient.
597
- Prefer match_summary for short answers and default cards.
598
-
599
- Do not use when: first-pass live board (use live_matches + match_summary).
600
-
601
- Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
602
- UFC betting lines: sections:["odds"] (opt-in, never in the default set).
603
- If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
604
-
605
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
614
+ description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
615
+
616
+ When to use:
617
+ - Analyst deep dive
618
+ - Live in-game window (LoL/CS2/UFC)
619
+ - Full demo list
620
+
621
+ Prefer over match_summary only when summary is insufficient.
622
+ Prefer match_summary for short answers and default cards.
623
+
624
+ Do not use when: first-pass live board (use live_matches + match_summary).
625
+
626
+ Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
627
+ UFC betting lines: sections:["odds"] (opt-in, never in the default set).
628
+ If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
629
+
630
+ Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
606
631
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
607
632
  inputSchema: {
608
633
  type: 'object',
@@ -136,7 +136,7 @@ function imagesFrom(game, ...sources) {
136
136
  headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
137
137
  bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
138
138
  imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
139
- proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
139
+ proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl', 'proxied_image_url'),
140
140
  };
141
141
  if (!images.proxiedImageUrl) {
142
142
  images.proxiedImageUrl =
@@ -508,7 +508,7 @@ export function normalizeMatch(game, row, forcedStatus) {
508
508
  const p1g = numOrNull(sr.player1_games);
509
509
  const p2g = numOrNull(sr.player2_games);
510
510
  return {
511
- setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
511
+ setNumber: numOrNull(sr.set_number) ?? numOrNull(sr.set_num),
512
512
  score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
513
513
  player1Games: p1g,
514
514
  player2Games: p2g,
@@ -557,6 +557,9 @@ export function normalizeMatch(game, row, forcedStatus) {
557
557
  ...(tennisSets
558
558
  ? { sets: tennisSets, gameScore: tennisGameScore, server: tennisServer, currentSet: tennisCurrentSet }
559
559
  : {}),
560
+ ...(game === 'tennis' && typeof r.is_simulated === 'boolean'
561
+ ? { isSimulated: r.is_simulated, liveFeed: pickString(r.live_feed) ?? null }
562
+ : {}),
560
563
  ...(isCancelled
561
564
  ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
562
565
  : {}),
@@ -35,7 +35,7 @@ function identityFrom(game, raw, idHint, slugHint) {
35
35
  headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
36
36
  bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
37
37
  imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
38
- proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
38
+ proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl, r.proxied_image_url) ?? null,
39
39
  license: pickString(r.portrait_license) ?? null,
40
40
  attribution: pickString(r.portrait_attribution) ?? null,
41
41
  sourceUrl: pickString(r.portrait_source_url) ?? null,
@@ -44,17 +44,17 @@ function identityFrom(game, raw, idHint, slugHint) {
44
44
  }
45
45
  export const playerProfile = {
46
46
  name: 'player_profile',
47
- description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
48
-
49
- When to use:
50
- - "How is X playing lately?"
51
- - Player page scaffold; form inputs for previews
52
-
53
- Prefer over: manual multi-call career/trends/matches via call_api.
54
-
55
- Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
-
57
- Parallel-safe: yes. Upstream cost: 2–5.
47
+ description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
48
+
49
+ When to use:
50
+ - "How is X playing lately?"
51
+ - Player page scaffold; form inputs for previews
52
+
53
+ Prefer over: manual multi-call career/trends/matches via call_api.
54
+
55
+ Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
+
57
+ Parallel-safe: yes. Upstream cost: 2–5.
58
58
  Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
59
59
  inputSchema: {
60
60
  type: 'object',
@@ -70,9 +70,9 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
70
70
  includeTrends: boolSchema('Include trends/radar/career enrichment when available.', true),
71
71
  view: {
72
72
  type: 'string',
73
- enum: ['summary', 'full'],
73
+ enum: ['summary', 'full', 'compact'],
74
74
  default: 'summary',
75
- description: 'Payload weight. Default summary.',
75
+ description: 'Payload weight. Default summary. compact (tennis): recentMatches as one slim row per match from the player side (result, opponent{id,name,rank}, score, event, round) instead of full match rows that repeat both players three times.',
76
76
  },
77
77
  },
78
78
  },
@@ -124,7 +124,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
124
124
  const idOrSlug = playerId || slug;
125
125
  const recentLimit = clampInt(args.recentLimit, 10, 1, 25);
126
126
  const includeTrends = args.includeTrends !== false;
127
- const view = args.view === 'full' ? 'full' : 'summary';
127
+ const view = args.view === 'full' ? 'full' : args.view === 'compact' ? 'compact' : 'summary';
128
128
  const partial = [];
129
129
  let upstreamCalls = 0;
130
130
  let rateLimit = {};
@@ -295,7 +295,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
295
295
  }
296
296
  tasks.push((async () => {
297
297
  const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/matches`, {
298
- query: { page_size: recentLimit },
298
+ query: { page_size: recentLimit, ...(view === 'compact' ? { view: 'summary' } : {}) },
299
299
  });
300
300
  upstreamCalls += 1;
301
301
  rateLimit = { ...rateLimit, ...res.headers };
@@ -534,6 +534,7 @@ function normalizeFormMatch(row) {
534
534
  result: pickString(r.result) ?? null,
535
535
  opponentId: pickString(r.opponent_id) ?? null,
536
536
  opponentRank: num(r.opponent_rank),
537
+ opponentRankDate: pickString(r.opponent_rank_date) ?? null,
537
538
  score: pickString(r.score) ?? null,
538
539
  surface: pickString(r.surface) ?? null,
539
540
  tournamentId: pickString(r.tournament_id) ?? null,
@@ -541,17 +542,17 @@ function normalizeFormMatch(row) {
541
542
  }
542
543
  export const playerForm = {
543
544
  name: 'player_form',
544
- description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
545
-
546
- When to use:
547
- - "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
548
-
549
- Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
550
-
551
- Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
552
-
553
- Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
554
-
545
+ description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
546
+
547
+ When to use:
548
+ - "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
549
+
550
+ Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
551
+
552
+ Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
553
+
554
+ Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
555
+
555
556
  Parallel-safe: yes. Upstream cost: 1.`,
556
557
  inputSchema: {
557
558
  type: 'object',
@@ -700,18 +701,18 @@ Parallel-safe: yes. Upstream cost: 1.`,
700
701
  };
701
702
  export const playerMatches = {
702
703
  name: 'player_matches',
703
- description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
704
-
705
- When to use:
706
- - "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
707
-
708
- Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
709
-
710
- Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
711
-
712
- Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
713
- surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
714
-
704
+ description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
705
+
706
+ When to use:
707
+ - "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
708
+
709
+ Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
710
+
711
+ Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
712
+
713
+ Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
714
+ surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
715
+
715
716
  Parallel-safe: yes. Upstream cost: 1.`,
716
717
  inputSchema: {
717
718
  type: 'object',
@@ -869,16 +870,16 @@ Parallel-safe: yes. Upstream cost: 1.`,
869
870
  };
870
871
  export const playerStats = {
871
872
  name: 'player_stats',
872
- description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
873
-
874
- When to use:
875
- - "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
876
-
877
- Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
878
-
879
- Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
880
-
881
- Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
873
+ description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
874
+
875
+ When to use:
876
+ - "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
877
+
878
+ Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
879
+
880
+ Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
881
+
882
+ Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
882
883
  Upstream cost: 1.`,
883
884
  inputSchema: {
884
885
  type: 'object',
@@ -10,8 +10,11 @@ function normalizeScheduled(row) {
10
10
  const p2 = asRecord(r.player2) ?? asRecord(r.away) ?? {};
11
11
  return {
12
12
  matchId: pickString(r.id, r.match_id) ?? null,
13
+ canonicalId: pickString(r.canonical_id, r.id, r.match_id) ?? null,
13
14
  tournamentId: pickString(r.tournament_id) ?? null,
14
15
  tournamentName: pickString(r.tournament_name, r.tournament) ?? null,
16
+ tournamentDisplayName: pickString(r.tournament_display_name, r.tournament_name) ?? null,
17
+ tournamentShortName: pickString(r.tournament_short_name) ?? null,
15
18
  tour: pickString(r.tour) ?? null,
16
19
  level: pickString(r.level, r.tier) ?? null,
17
20
  surface: pickString(r.surface) ?? null,
@@ -20,9 +23,23 @@ function normalizeScheduled(row) {
20
23
  startsAt: pickString(r.starts_at, r.commence_time, r.match_date) ?? null,
21
24
  startTimeKnown: typeof r.start_time_known === 'boolean' ? r.start_time_known : null,
22
25
  status: pickString(r.status, r.outcome) ?? null,
23
- player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
24
- player2: { id: pickString(p2.id, p2.player_id) ?? null, name: pickString(p2.name) ?? null },
26
+ player1: {
27
+ id: pickString(p1.id, p1.player_id) ?? null,
28
+ canonicalId: pickString(p1.canonical_id, p1.id, p1.player_id) ?? null,
29
+ name: pickString(p1.name) ?? null,
30
+ },
31
+ player2: {
32
+ id: pickString(p2.id, p2.player_id) ?? null,
33
+ canonicalId: pickString(p2.canonical_id, p2.id, p2.player_id) ?? null,
34
+ name: pickString(p2.name) ?? null,
35
+ },
36
+ winnerId: pickString(r.winner_id) ?? null,
25
37
  score: pickString(r.score, r.score_raw) ?? null,
38
+ liveScore: pickString(r.live_score) ?? null,
39
+ resultAvailable: typeof r.result_available === 'boolean'
40
+ ? r.result_available
41
+ : Boolean(pickString(r.winner_id) && pickString(r.score, r.score_raw)),
42
+ resultUnavailableReason: pickString(r.result_unavailable_reason) ?? null,
26
43
  };
27
44
  }
28
45
  function isPlayed(row) {
@@ -116,7 +133,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
116
133
  const limit = clampInt(args.limit, 20, 1, 50);
117
134
  const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
118
135
  const wantCompleted = includeRaw === 'both' || includeRaw === 'completed';
119
- const upstreamPage = 200;
136
+ const upstreamPage = 500;
120
137
  const settled = await Promise.allSettled([
121
138
  wantScheduled
122
139
  ? fetchJson(ctx, '/tennis/schedule', { query: { date, limit: upstreamPage } })
@@ -197,14 +214,14 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
197
214
  const scheduledWindow = scheduled.slice(0, limit);
198
215
  const completedWindow = completed.slice(0, limit);
199
216
  const completedBySchedule = scheduled.filter(isPlayed);
200
- const completedIds = new Set(completed
201
- .map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
202
- .filter((id) => Boolean(id)));
217
+ const idOf = (row) => pickString(asRecord(row)?.canonical_id, asRecord(row)?.id, asRecord(row)?.match_id);
218
+ const completedIds = new Set(completed.map(idOf).filter((id) => Boolean(id)));
203
219
  const overlapIds = [
204
220
  ...new Set(completedBySchedule
205
- .map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
221
+ .map(idOf)
206
222
  .filter((id) => typeof id === 'string' && completedIds.has(id))),
207
223
  ];
224
+ const resultMissing = scheduled.filter((row) => isPlayed(row) && asRecord(row)?.result_available === false).length;
208
225
  const statusCounts = {};
209
226
  for (const row of scheduled) {
210
227
  const key = (pickString(asRecord(row)?.status, asRecord(row)?.outcome) ?? 'UNKNOWN').toUpperCase();
@@ -222,6 +239,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
222
239
  completedCount: completedTotal ?? completed.length,
223
240
  completedReturned: completedWindow.length,
224
241
  statusBreakdown: statusCounts,
242
+ playedWithoutResult: resultMissing,
225
243
  overlap: {
226
244
  count: overlapIds.length,
227
245
  matchIds: overlapIds.slice(0, 50),
@@ -858,9 +858,10 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
858
858
  ],
859
859
  });
860
860
  }
861
- const res = await fetchJson(ctx, '/tennis/h2h', {
862
- query: { player1_id: tA.id, player2_id: tB.id },
863
- });
861
+ const tennisQuery = { player1_id: tA.id, player2_id: tB.id };
862
+ if (args.limit !== undefined && args.limit !== null)
863
+ tennisQuery.limit = limit;
864
+ const res = await fetchJson(ctx, '/tennis/h2h', { query: tennisQuery });
864
865
  upstreamCalls += 1;
865
866
  rateLimit = { ...rateLimit, ...res.headers };
866
867
  if (!res.ok) {
@@ -893,7 +894,16 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
893
894
  sideA: { idOrSlug: tA.id, name: sideAName },
894
895
  sideB: { idOrSlug: tB.id, name: sideBName },
895
896
  h2h,
896
- notes: ['Record and meetings come from the first-class /tennis/h2h endpoint'],
897
+ meetingsReturned: Array.isArray(h2h.matches) ? h2h.matches.length : null,
898
+ meetingsTotal: typeof asRecord(h2h.matches_pagination)?.total === 'number'
899
+ ? asRecord(h2h.matches_pagination)?.total
900
+ : typeof asRecord(h2h.summary)?.total_matches === 'number'
901
+ ? asRecord(h2h.summary)?.total_matches
902
+ : null,
903
+ notes: [
904
+ 'Record and meetings come from the first-class /tennis/h2h endpoint',
905
+ 'limit bounds h2h.matches only; summary and every breakdown (surface, level, format) cover all meetings and sum to total_matches (h2h.breakdown_totals).',
906
+ ],
897
907
  },
898
908
  });
899
909
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
6
  "homepage": "https://cito.gg",