cito-mcp 0.3.0 → 0.3.2

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.
@@ -167,9 +167,26 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
167
167
  const partial = [];
168
168
  let upstreamCalls = 0;
169
169
  let rateLimit = {};
170
- const primary = await getSection(ctx, primaryPath(game, matchId));
170
+ let primary = await getSection(ctx, primaryPath(game, matchId));
171
171
  upstreamCalls += 1;
172
172
  rateLimit = primary.headers;
173
+ // Tennis live board hands out s365_* ids that live at /matches/live/{id}
174
+ // until the match finishes and lands in the archive. Without this fallback
175
+ // the board gives out ids that match_summary immediately 404s on.
176
+ if (!primary.ok && game === 'tennis' && primary.status === 404) {
177
+ primary = await getSection(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
178
+ upstreamCalls += 1;
179
+ rateLimit = { ...rateLimit, ...primary.headers };
180
+ // Finished live matches archive under s365_{year}_{gid} while the live
181
+ // board handed out s365_{gid} — insert the year before giving up.
182
+ const gid = matchId.match(/^s365_(\d+)$/)?.[1];
183
+ if (!primary.ok && gid) {
184
+ const year = new Date().getUTCFullYear();
185
+ primary = await getSection(ctx, `/tennis/matches/${encodeURIComponent(`s365_${year}_${gid}`)}`);
186
+ upstreamCalls += 1;
187
+ rateLimit = { ...rateLimit, ...primary.headers };
188
+ }
189
+ }
173
190
  if (!primary.ok) {
174
191
  return errorEnvelope({
175
192
  code: mapHttpToCode(primary.status, { gameNotIncluded: gameNotIncludedHint(primary.data) }),
@@ -430,6 +430,7 @@ Example: { "includeGameProbes": true }`,
430
430
  { game: 'dota2', path: '/dota2' },
431
431
  { game: 'cod', path: '/cod' },
432
432
  { game: 'ufc', path: '/ufc/live/health' },
433
+ { game: 'tennis', path: '/tennis/rankings/top?tour=ATP&top_n=1' },
433
434
  ];
434
435
  const results = await Promise.all(paths.map(async ({ game, path }) => {
435
436
  const res = await fetchJson(ctx, path);
@@ -528,6 +529,10 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
528
529
  default: '{}',
529
530
  description: 'Stringified JSON object of query params. Example: "{\\"page\\":1,\\"limit\\":20}".',
530
531
  },
532
+ query: {
533
+ type: 'object',
534
+ description: 'Query params as a plain object (alternative to queryJson). Example: {"year":2026}.',
535
+ },
531
536
  bodyJson: {
532
537
  type: 'string',
533
538
  description: 'Stringified JSON body for POST only (rare).',
@@ -571,6 +576,11 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
571
576
  });
572
577
  }
573
578
  let query = {};
579
+ // Agents pass query as a plain object at least as often as the stringified
580
+ // queryJson form; silently dropping it produced confusing upstream 422s.
581
+ if (args.query && typeof args.query === 'object' && !Array.isArray(args.query)) {
582
+ query = { ...args.query };
583
+ }
574
584
  if (typeof args.queryJson === 'string' && args.queryJson.trim()) {
575
585
  try {
576
586
  const parsed = JSON.parse(args.queryJson);
@@ -692,10 +702,10 @@ Example: { "game": "ufc", "q": "rankings" }`,
692
702
  // documented paths and no curated tools, so it is exactly what call_api
693
703
  // callers come looking for.
694
704
  const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
695
- if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
705
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite|tennis)$/)) {
696
706
  return errorEnvelope({
697
707
  code: 'UNSUPPORTED_GAME',
698
- message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
708
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite|tennis`,
699
709
  game: null,
700
710
  source: 'list_routes',
701
711
  requestId,
@@ -171,21 +171,33 @@ export function normalizeMatch(game, row, forcedStatus) {
171
171
  if (s != null)
172
172
  team2 = { ...team2, score: s };
173
173
  }
174
- // Tennis: live rows carry player1_name/player2_name; archive rows only carry
175
- // winner_id/loser_id (ids, no names). Map both onto sides so labels are never "? vs ?".
174
+ // Tennis: the live board nests player1/player2 objects ({id,name,sets_won});
175
+ // raw live rows carry player1_name/player2_name; archive rows only carry
176
+ // winner_id/loser_id (ids, no names). Map all three onto sides so labels are
177
+ // never "? vs ?" and live scores surface as sets won.
176
178
  if (game === 'tennis' && !team1 && !team2) {
177
- const p1 = pickString(r.player1_name);
178
- const p2 = pickString(r.player2_name);
179
+ const p1obj = asRecord(r.player1);
180
+ const p2obj = asRecord(r.player2);
181
+ const p1 = pickString(p1obj?.name, r.player1_name);
182
+ const p2 = pickString(p2obj?.name, r.player2_name);
179
183
  if (p1 || p2) {
180
- team1 = sideFrom(p1, pickString(r.player1_id), undefined, undefined);
181
- team2 = sideFrom(p2, pickString(r.player2_id), undefined, undefined);
184
+ const score1 = typeof p1obj?.sets_won === 'number' ? p1obj.sets_won : undefined;
185
+ const score2 = typeof p2obj?.sets_won === 'number' ? p2obj.sets_won : undefined;
186
+ team1 = sideFrom(p1, pickString(p1obj?.id, r.player1_id), undefined, score1);
187
+ team2 = sideFrom(p2, pickString(p2obj?.id, r.player2_id), undefined, score2);
182
188
  }
183
189
  else {
184
- const w = pickString(r.winner_id);
185
- const l = pickString(r.loser_id);
186
- if (w || l) {
187
- team1 = sideFrom(w, w, undefined, undefined);
188
- team2 = sideFrom(l, l, undefined, undefined);
190
+ // Archive detail nests winner/loser objects with real names; list rows
191
+ // may only carry winner_id/loser_id (ids double as last-resort labels).
192
+ const wObj = asRecord(r.winner);
193
+ const lObj = asRecord(r.loser);
194
+ const wName = pickString(wObj?.name, r.winner_name);
195
+ const lName = pickString(lObj?.name, r.loser_name);
196
+ const wId = pickString(wObj?.id, r.winner_id);
197
+ const lId = pickString(lObj?.id, r.loser_id);
198
+ if (wName || lName || wId || lId) {
199
+ team1 = sideFrom(wName ?? wId, wId, undefined, undefined);
200
+ team2 = sideFrom(lName ?? lId, lId, undefined, undefined);
189
201
  }
190
202
  }
191
203
  }
@@ -286,7 +298,7 @@ export function normalizeMatch(game, row, forcedStatus) {
286
298
  }
287
299
  }
288
300
  const ev = asRecord(r.event);
289
- const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName);
301
+ const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName, r.tournament_name);
290
302
  const eventId = pickString(ev?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
291
303
  const eventSlug = pickString(ev?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
292
304
  const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
@@ -10,7 +10,14 @@ function pushCandidates(out, game, type, rows, q, limit) {
10
10
  const ref = entityRef(row, type, game);
11
11
  const r = asRecord(row) ?? {};
12
12
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
13
- const score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
13
+ let score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
14
+ // Ranked-player tiebreaker: a surname query like "Alcaraz" fuzzy-ties the
15
+ // world #2 with a 1991 journeyman; the row's current_rank breaks the tie
16
+ // toward whoever is actually active/ranked (tennis search supplies it).
17
+ const currentRank = Number(r.current_rank);
18
+ if (score > 0 && Number.isFinite(currentRank) && currentRank > 0) {
19
+ score += currentRank <= 100 ? 8 : currentRank <= 1000 ? 4 : 2;
20
+ }
14
21
  // Prefer positive fuzzy hits; keep weak API hits at floor 1 so real search results are not wiped.
15
22
  if (q && score <= 0) {
16
23
  // still allow through at floor so dedicated search endpoints aren't empty on odd nicknames
@@ -62,7 +62,7 @@ export function normalizeStandingRow(row, index) {
62
62
  : null),
63
63
  championStatus: resolvedChampionStatus,
64
64
  teamOrFighter: {
65
- id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
65
+ id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id, r.player_id),
66
66
  slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
67
67
  name,
68
68
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {