cito-mcp 0.4.1 → 0.4.3

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
@@ -213,9 +213,27 @@ When a key is `null` the image does not exist for that entity; when the whole
213
213
  never partially shaped — if any image exists, all four keys are present.
214
214
 
215
215
  Tennis is the exception on purpose: the feed supplies a single `portrait_url`
216
- (Wikimedia) and no square crop, so that one URL fills both `headshotUrl` and
217
- `imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
218
- a proxy. A card with the real photo slightly cropped beats a faceless card.
216
+ (Wikimedia Commons) and no square crop, so that one URL fills both `headshotUrl`
217
+ and `imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
218
+ a proxy.
219
+
220
+ **Tennis images carry their licence and credit** — and a UI is expected to render
221
+ them:
222
+
223
+ ```jsonc
224
+ "images": {
225
+ "headshotUrl": "https://commons.wikimedia.org/wiki/Special:FilePath/Hoang_WMQ18_%2833%29_%2842647295495%29.jpg?width=400",
226
+ "license": "CC BY-SA 2.0",
227
+ "attribution": "si.robi",
228
+ "sourceUrl": "https://commons.wikimedia.org/wiki/File:Hoang_WMQ18_(33)_(42647295495).jpg"
229
+ }
230
+ ```
231
+
232
+ Commons portraits are mostly CC BY / CC BY-SA, which for commercial display
233
+ generally requires naming the author and stating the licence. `attribution` is
234
+ `null` for public-domain and CC0 images, which need no credit — that is a fact,
235
+ not missing data. Anything without a defensible licence has its URL removed
236
+ upstream, so a non-null `headshotUrl` always has a `license` beside it.
219
237
 
220
238
  ### Games
221
239
 
@@ -525,6 +543,19 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
525
543
 
526
544
  ## Changelog (summary)
527
545
 
546
+ ### 0.4.2
547
+
548
+ Second tennis correctness pass, from the 2026-09-12 re-sweep. Five defects, each reproduced from a raw payload and re-verified after the fix. Two were upstream (fixed on the API and verified with the 40/40 invariant gate); three were MCP-side.
549
+
550
+ - **`tennis_odds` called a nonexistent match "no coverage".** `/tennis/odds/{id}` answers **HTTP 200 with `coverage.odds=false` for every id it does not know** — verified for `s365_2026_0000000` and for the literal `total-garbage-id`. So a typo and a genuine no-book gap were indistinguishable and both came back `ok:true`. `scope=match`/`live` now verify the id against `/tennis/matches/{id}` (which does 404) when no odds are quoted: unknown id → `NOT_FOUND` with recovery steps, real match → an honest coverage gap with `matchExists: true`.
551
+ - **Tennis `standings` published a fake total and could not be paged.** `/tennis/standings?top_n=5` set `rank_max=top_n`, so the count (which carried the rank bounds) equalled the page size: `total:5`, `hasMore:false` for a list of **150** ranked players. The API now counts the population without the rank window and returns `ranked_players`/`total_pages`/`has_next`, `/standings` takes a `page` argument, and the tool reports `total`/`rankedPlayers`/`totalPages`/`hasMore` — page 2 of the ATP list now starts at rank 6. The same `allRows.length` mistake in the `pagination` block was fixed too, so the two counts cannot disagree.
552
+ - **`event_card` published the requested id as the event name.** With a bad id it answered `ok:true` and `event.name = "atp_2026_999999"` over a 404 that `partial[]` reported correctly. Names now come from the payload or are `null`, with `event.resolved` recording whether the detail fetch succeeded.
553
+ - **`leaderboard_*` reported the page size as the population.** Every board returned `"total": len(items)` after applying `.limit(limit)`, so `limit=2` answered `total:2` — for boards whose own docs record thousands of qualifiers. Upstream now carries `count(*) OVER ()` (Postgres evaluates it after `GROUP BY`/`HAVING`, so it is exactly the qualifying population, at no extra round trip): aces **2488**, break-conversion **1089** (matching the figure in its own docstring), 1st-serve-won 1141, bp-saved 1186, return-games 987, tiebreak 364, deciders 334, comebacks 611, finals 66. Each tool also reports `returned` and the board's `minimumAttempts`.
554
+ - **`resolve_entity { q: "Nole" }` answered "M Canoles".** The upstream name search puts Novak Djokovic first for `Nole` — it knows a nickname nothing in the payload carries, so `rankScore` scores it 0 — and the tool floored every unmatched row to 1 and then *sorted*, letting a substring collision win. `pushCandidates` now lets the upstream's own first row stand as the alias answer (labelled `meta.matchedBy: "upstream-first"`) when our scorer is silent, and `search_entities` exempts exactly that one row from its relevance floor. Padding is still dropped: `Sinner` still returns only Sinner.
555
+ - **`/tennis/tournaments/calendar` silently ignored `?month=`.** FastAPI drops unknown query parameters, so `?month=9` and `?month=3` returned the identical 621-event season list and a caller asking "what is on in September" got the whole year. The route now takes `month` and filters on the event's start month (2026-09 → 12 events).
556
+ - **`standings` and `leaderboards` were never cached**, so every call re-ran its grouped scan and paid db1's ~90ms RTT per query: 474–922ms on *every* request while every cached route answered in <20ms. That held eleven tools permanently over the 400ms latency budget. Both prefixes now have TTLs (1800s / 600s); repeat calls measure **4–5ms**.
557
+ - **`bestOf`** now reads `best_of`, the only spelling the tennis route emits.
558
+
528
559
  ### 0.4.1
529
560
 
530
561
  Tennis correctness pass, driven by the 2026-09-12 sweep (`reports/tennis-mcp-sweep-2026-09-12.md`). Every item was reproduced against live ids and re-verified after the fix.
@@ -882,10 +882,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
882
882
  });
883
883
  }
884
884
  }
885
+ /**
886
+ * Seeded with the key but explicitly NOT resolved.
887
+ *
888
+ * `name: q || eventKey` used to put the raw requested id in the name slot,
889
+ * and the tennis branch then kept it whenever its detail fetch failed
890
+ * (`pickString(d.name) ?? event.name`). event_card { eventIdOrSlug:
891
+ * "atp_2026_999999" } therefore answered ok:true with
892
+ * event.name = "atp_2026_999999" — an id presented as a tournament name,
893
+ * over a 404 that the partial[] block reported correctly. A name is
894
+ * something the API supplied or it is null; an identifier is not a name.
895
+ */
885
896
  let event = {
886
897
  id: eventKey,
887
898
  slug: eventKey,
888
- name: q || eventKey,
899
+ name: q || null,
900
+ resolved: false,
889
901
  game,
890
902
  };
891
903
  let bouts = [];
@@ -1308,7 +1320,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1308
1320
  ...event,
1309
1321
  id: pickString(d.id) ?? eventKey,
1310
1322
  slug: pickString(d.id) ?? eventKey,
1311
- name: pickString(d.name) ?? event.name,
1323
+ // Never fall back to event.name here: that is the requested key, and
1324
+ // this branch only runs when the detail fetch SUCCEEDED, so the name
1325
+ // comes from the payload or it is honestly absent.
1326
+ name: pickString(d.name, d.id) ?? null,
1327
+ resolved: true,
1312
1328
  tour: pickString(d.tour) ?? null,
1313
1329
  level: pickString(d.level) ?? null,
1314
1330
  tier: pickString(d.tier) ?? null,
@@ -1446,7 +1462,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1446
1462
  message: `dota tournament HTTP ${detail.status}`,
1447
1463
  httpStatus: detail.status,
1448
1464
  }));
1449
- event = { id: eventKey, slug: eventKey, name: q || eventKey, game };
1465
+ event = { id: eventKey, slug: eventKey, name: q || null, resolved: false, game };
1450
1466
  }
1451
1467
  if (includeBouts) {
1452
1468
  const matchesRes = await fetchJson(ctx, '/dota2/matches/upcoming', { query: { limit } });
@@ -1481,6 +1497,18 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1481
1497
  }));
1482
1498
  }
1483
1499
  }
1500
+ /**
1501
+ * Last line of defence against an identifier posing as a name.
1502
+ *
1503
+ * Every game branch seeds `event.name` from `q`/`eventKey` so a partially
1504
+ * resolved card still has something in the slot. Any branch that failed and
1505
+ * left that seed in place would publish "atp_2026_999999" as a tournament
1506
+ * name, and nothing downstream can tell an id from a name. It is nulled
1507
+ * here; `resolved` records that the detail fetch never succeeded.
1508
+ */
1509
+ if (event.resolved !== true && typeof event.name === 'string' && event.name === eventKey) {
1510
+ event = { ...event, name: null, nameResolved: false };
1511
+ }
1484
1512
  return successEnvelope({
1485
1513
  source: 'event_card',
1486
1514
  game,
@@ -156,13 +156,23 @@ Parallel-safe: yes. Upstream cost: 1.`,
156
156
  const root = asRecord(res.data);
157
157
  const body = asRecord(root?.data) ?? root ?? {};
158
158
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeAceRow);
159
+ /**
160
+ * `total` is the QUALIFYING POPULATION, not the page.
161
+ *
162
+ * The API used to return len(items) AFTER applying .limit(limit), so limit=2
163
+ * answered total:2 for a board with thousands of eligible players and the
164
+ * caller had no way to tell a page from the whole board. It now carries
165
+ * count(*) OVER (), the count of players passing the board's own threshold.
166
+ * `returned` and `hasMore` are stated separately so the two can never be
167
+ * confused again, and the threshold is surfaced so the number is explicable.
168
+ */
159
169
  const total = typeof body.total === 'number' ? body.total : items.length;
160
170
  return successEnvelope({
161
171
  pagination: {
162
172
  limit,
163
173
  offset: 0,
164
174
  total,
165
- hasMore: false,
175
+ hasMore: items.length < total,
166
176
  nextCursor: null,
167
177
  prevCursor: null,
168
178
  },
@@ -179,6 +189,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
179
189
  stat: pickString(body.stat) ?? 'aces',
180
190
  items,
181
191
  total,
192
+ returned: items.length,
193
+ ...(typeof body.minimum_attempts === 'number' ? { minimumAttempts: body.minimum_attempts } : {}),
182
194
  },
183
195
  });
184
196
  },
@@ -306,13 +318,15 @@ Parallel-safe: yes. Upstream cost: 1.`,
306
318
  const root = asRecord(res.data);
307
319
  const body = asRecord(root?.data) ?? root ?? {};
308
320
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeRow);
321
+ // Same contract as leaderboard_aces: total is the qualifying population,
322
+ // not the page. See the note there.
309
323
  const total = typeof body.total === 'number' ? body.total : items.length;
310
324
  return successEnvelope({
311
325
  pagination: {
312
326
  limit,
313
327
  offset: 0,
314
328
  total,
315
- hasMore: false,
329
+ hasMore: items.length < total,
316
330
  nextCursor: null,
317
331
  prevCursor: null,
318
332
  },
@@ -329,6 +343,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
329
343
  stat: pickString(body.stat) ?? spec.stat,
330
344
  items,
331
345
  total,
346
+ returned: items.length,
347
+ ...(typeof body.minimum_attempts === 'number' ? { minimumAttempts: body.minimum_attempts } : {}),
332
348
  },
333
349
  });
334
350
  },
@@ -125,6 +125,10 @@ Do not use when: the match result → match_details; rankings → standings.
125
125
  Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
126
126
  priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
127
127
  than invented numbers, and this tool surfaces that distinction explicitly.
128
+ A match id that does not exist is a NOT_FOUND error, not a coverage gap: the
129
+ odds route answers 200/odds:false for every unknown id, so scope=match and
130
+ scope=live verify the match against /tennis/matches/{id} before reporting a gap,
131
+ and set matchExists.
128
132
 
129
133
  Parallel-safe: yes. Upstream cost: 1.`,
130
134
  inputSchema: {
@@ -259,6 +263,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
259
263
  ? `/tennis/odds/${encodeURIComponent(matchId)}`
260
264
  : `/tennis/odds/${encodeURIComponent(matchId)}/live`;
261
265
  const res = await fetchJson(ctx, path, {});
266
+ let upstreamCalls = 1;
267
+ let rateLimit = res.headers;
262
268
  if (!res.ok) {
263
269
  return errorEnvelope({
264
270
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -274,13 +280,62 @@ Parallel-safe: yes. Upstream cost: 1.`,
274
280
  });
275
281
  }
276
282
  const summary = summarizeTennisOdds(res.data);
283
+ /**
284
+ * A match id that does not exist and a real match nobody is quoting look
285
+ * identical on the wire.
286
+ *
287
+ * /tennis/odds/{id} answers HTTP 200 with `coverage: { odds: false }` for
288
+ * EVERY id it does not know — verified for `s365_2026_0000000` (a plausible
289
+ * typo) and for the literal string `total-garbage-id`, both of which came
290
+ * back 200/odds:false. So the status code cannot tell them apart, and this
291
+ * tool used to report a typo as "upstream coverage gap, not an error" with
292
+ * ok:true — leaving the caller to conclude a real match has no prices.
293
+ *
294
+ * /tennis/matches/{id} DOES 404 for those same ids (verified: 404 NOT_FOUND
295
+ * for both, 200 for a real one), so that is the discriminator. It costs one
296
+ * extra call, and only on the no-odds path, where the answer is otherwise
297
+ * useless. player_stats already gets this right (404 -> NOT_FOUND); this
298
+ * makes odds agree with it.
299
+ */
300
+ let matchExists = null;
301
+ if (summary.oddsAvailable !== true) {
302
+ const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
303
+ upstreamCalls += 1;
304
+ rateLimit = { ...rateLimit, ...probe.headers };
305
+ matchExists = probe.ok;
306
+ if (!matchExists) {
307
+ // Live ids live at /matches/live/{id} until the match archives.
308
+ const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
309
+ upstreamCalls += 1;
310
+ rateLimit = { ...rateLimit, ...liveProbe.headers };
311
+ matchExists = liveProbe.ok;
312
+ }
313
+ if (!matchExists) {
314
+ return errorEnvelope({
315
+ code: 'NOT_FOUND',
316
+ message: `No tennis match with id '${matchId}' — the odds feed has no event for it because the id itself does not exist`,
317
+ game,
318
+ source: 'tennis_odds',
319
+ requestId,
320
+ tookMs: Date.now() - started,
321
+ upstreamCalls,
322
+ rateLimit,
323
+ httpStatus: 200,
324
+ recover: [
325
+ 'Check the id with live_matches or tennis_schedule / match_summary',
326
+ 'Ids come from live_matches (s365_*) or the archive (s365_{year}_{gid})',
327
+ `For the pre-match odds list instead, call tennis_odds { game: "tennis", scope: "upcoming" }`,
328
+ ],
329
+ });
330
+ }
331
+ }
277
332
  return successEnvelope({
278
333
  source: 'tennis_odds',
279
334
  game,
280
335
  requestId,
281
336
  tookMs: Date.now() - started,
282
- upstreamCalls: 1,
283
- rateLimit: res.headers,
337
+ upstreamCalls,
338
+ rateLimit,
284
339
  data: {
285
340
  title: `Tennis ${scopeRaw} odds — ${matchId}`,
286
341
  scope: scopeRaw,
@@ -288,6 +343,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
288
343
  // returns. See summarizeTennisOdds.
289
344
  ...summary,
290
345
  matchId: summary.matchId ?? matchId,
346
+ // true when the no-odds path verified the match exists; null when odds
347
+ // existed so no verification was needed.
348
+ matchExists,
291
349
  },
292
350
  });
293
351
  },
@@ -54,16 +54,29 @@ function identityFrom(game, raw, idHint, slugHint) {
54
54
  */
55
55
  images: {
56
56
  // Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
57
- // portrait), and no separate square headshot crop. Before this, the shaper
58
- // read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of which the
59
- // tennis route emits — so every tennis profile shipped four explicit nulls
60
- // while a perfectly good photo sat one key away, and builders concluded
61
- // tennis had no images at all. The portrait fills the headshot slot too:
62
- // a card with the real photo slightly cropped beats a faceless card.
57
+ // Commons portrait), and no separate square headshot crop. Before this,
58
+ // the shaper read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of
59
+ // which the tennis route emits — so every tennis profile shipped four
60
+ // explicit nulls while a perfectly good photo sat one key away.
63
61
  headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
64
62
  bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
65
63
  imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
66
64
  proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
65
+ /**
66
+ * The licence and the credit, carried with the URL.
67
+ *
68
+ * Commons portraits are mostly CC BY / CC BY-SA, which for commercial
69
+ * display generally obliges you to name the author and state the licence.
70
+ * Handing back a bare URL and letting a builder render it silently is what
71
+ * gets a product a takedown letter, so the fields a UI must show travel in
72
+ * the same object as the image. `attribution` is null for public-domain
73
+ * and CC0 images, which need no credit — that is a fact, not missing data.
74
+ * Anything without a defensible licence has its URL removed upstream, so a
75
+ * non-null headshotUrl here always has a licence beside it.
76
+ */
77
+ license: pickString(r.portrait_license) ?? null,
78
+ attribution: pickString(r.portrait_attribution) ?? null,
79
+ sourceUrl: pickString(r.portrait_source_url) ?? null,
67
80
  },
68
81
  };
69
82
  }
@@ -44,7 +44,11 @@ const ORG_ALIASES = {
44
44
  skt1: 't1',
45
45
  };
46
46
  function pushCandidates(out, game, type, rows, q, limit) {
47
+ // Position within the upstream's own result list. That ordering is a real
48
+ // signal we otherwise throw away.
49
+ let index = -1;
47
50
  for (const row of rows) {
51
+ index += 1;
48
52
  const ref = entityRef(row, type, game);
49
53
  const r = asRecord(row) ?? {};
50
54
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
@@ -56,9 +60,26 @@ function pushCandidates(out, game, type, rows, q, limit) {
56
60
  if (score > 0 && Number.isFinite(currentRank) && currentRank > 0) {
57
61
  score += currentRank <= 100 ? 8 : currentRank <= 1000 ? 4 : 2;
58
62
  }
59
- // Prefer positive fuzzy hits; keep weak API hits at floor 1 so real search results are not wiped.
60
- if (q && score <= 0) {
61
- // still allow through at floor so dedicated search endpoints aren't empty on odd nicknames
63
+ /**
64
+ * The upstream's FIRST row is allowed to match on something we cannot see.
65
+ *
66
+ * /tennis/players/search is a dedicated name search and it knows nicknames
67
+ * and aliases that the payload does not carry: `q=Nole` returns Novak
68
+ * Djokovic first, and nothing in the string "Novak Djokovic" resembles
69
+ * "nole", so rankScore scores it 0. The old code floored every unmatched row
70
+ * to 1 and then sorted, which let a mere substring collision win —
71
+ * `resolve_entity { q: "Nole" }` answered **M Canoles** (a WTA player whose
72
+ * name contains "noles") and search_entities dropped Djokovic entirely.
73
+ *
74
+ * So when our own scorer finds no name match at all but the dedicated search
75
+ * put the row first, the row is kept as the alias answer and labelled. Only
76
+ * the first row, and only when our scorer is silent, so this can never
77
+ * outrank a query that genuinely matches a name.
78
+ */
79
+ let matchedBy;
80
+ if (q && score <= 0 && index === 0) {
81
+ score = 95;
82
+ matchedBy = 'upstream-first';
62
83
  }
63
84
  const secondary = {};
64
85
  for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
@@ -68,6 +89,8 @@ function pushCandidates(out, game, type, rows, q, limit) {
68
89
  }
69
90
  if (nickname)
70
91
  secondary.nickname = nickname;
92
+ if (matchedBy)
93
+ secondary.matchedBy = matchedBy;
71
94
  out.push({
72
95
  game,
73
96
  type: pickString(r.entity_type, r.type, type) ?? type,
@@ -868,19 +891,59 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
868
891
  : rankScore(q, it.name, it.id, it.slug, it.meta?.nickname);
869
892
  const upstreamTotal = total;
870
893
  let droppedBelowFloor = [];
894
+ /**
895
+ * The upstream's own top ENTITY row is exempt from the floor.
896
+ *
897
+ * The upstream's dedicated search knows aliases the payload does not carry:
898
+ * `q=Nole` returns Novak Djokovic first, and rankScore cannot see why, so a
899
+ * pure floor would delete the correct answer and keep substring collisions
900
+ * instead.
901
+ *
902
+ * This used to be the global items[0], which was wrong: search_entities
903
+ * merges several sources CONCURRENTLY (for tennis, /players/search and
904
+ * /competitions), so whichever promise settled first decided which row got
905
+ * protected. The live gate caught it as a flaky "first result is Jannik
906
+ * Sinner" — a competition could land at index 0 and the alias protection
907
+ * would guard the wrong thing. Only entity rows (player/team/fighter) are
908
+ * alias-matched at all; tournaments are matched by name and have no
909
+ * nicknames, so taking the first entity row is both deterministic and the
910
+ * row the rule was written for.
911
+ */
912
+ const ENTITY_TYPES = new Set(['player', 'team', 'fighter']);
913
+ const protectedRow = q ? items.find((it) => ENTITY_TYPES.has(it.type)) ?? null : null;
914
+ const protectedKey = protectedRow ? `${protectedRow.game}:${protectedRow.type}:${protectedRow.id}` : null;
871
915
  if (q) {
872
916
  // Publish the score on every row, not just the ones the scored branch
873
917
  // produced, so the floor is visible rather than mysterious.
874
- items = items.map((it) => ({ ...it, meta: { ...(it.meta ?? {}), score: scoreOf(it) } }));
918
+ items = items.map((it) => {
919
+ const key = `${it.game}:${it.type}:${it.id}`;
920
+ const isProtected = protectedKey !== null && key === protectedKey;
921
+ const raw = scoreOf(it);
922
+ return {
923
+ ...it,
924
+ meta: {
925
+ ...(it.meta ?? {}),
926
+ score: raw,
927
+ ...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'upstream-first' } : {}),
928
+ },
929
+ };
930
+ });
931
+ const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'upstream-first';
875
932
  droppedBelowFloor = items
876
- .filter((it) => it.meta?.score < RELEVANCE_FLOOR)
933
+ .filter((it) => !keep(it))
877
934
  .map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
878
- if (droppedBelowFloor.length) {
879
- items = items.filter((it) => it.meta?.score >= RELEVANCE_FLOOR);
880
- }
935
+ if (droppedBelowFloor.length)
936
+ items = items.filter(keep);
881
937
  }
938
+ // An explicit (possibly rank-0) alias match beats a higher-scoring substring
939
+ // collision: the upstream's own top hit is the answer to a query we cannot
940
+ // score. This is what makes "Nole" resolve to Djokovic instead of M Canoles.
882
941
  if (q && !preserveUpstreamOrder) {
883
- items.sort((a, b) => scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name));
942
+ items.sort((a, b) => {
943
+ const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
944
+ const bm = b.meta?.matchedBy === 'upstream-first' ? 1 : 0;
945
+ return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
946
+ });
884
947
  }
885
948
  // pagination over ranked result set
886
949
  const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
@@ -126,6 +126,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
126
126
  stage: stringSchema('Stage key (COD / LoL).'),
127
127
  division: stringSchema('UFC division key.'),
128
128
  limit: limitSchema({ default: 50, max: 100 }),
129
+ page: {
130
+ type: 'integer',
131
+ minimum: 1,
132
+ description: 'Tennis only: page over the full published ranking list. With limit=5 the ATP list is 30 pages (150 ranked players); the response reports total/rankedPlayers/totalPages/hasMore.',
133
+ },
129
134
  cursor: stringSchema('Opaque cursor from pagination.nextCursor. UFC world scope spans every division; page with it rather than raising limit.'),
130
135
  },
131
136
  },
@@ -155,6 +160,10 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
155
160
  const season = typeof args.season === 'string' ? args.season : undefined;
156
161
  const stage = typeof args.stage === 'string' ? args.stage : undefined;
157
162
  const division = typeof args.division === 'string' ? args.division : undefined;
163
+ // Tennis rankings are pageable now that the API reports the real list size.
164
+ const tennisPage = typeof args.page === 'number' && Number.isFinite(args.page) && args.page >= 1
165
+ ? Math.trunc(args.page)
166
+ : 1;
158
167
  let path = '';
159
168
  let query = {};
160
169
  let title = `${game} standings`;
@@ -247,7 +256,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
247
256
  // rank_movement/previous_rank on every row. Pass tour via division.
248
257
  const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
249
258
  path = '/tennis/standings';
250
- query = { tour, top_n: Math.min(limit, 100) };
259
+ query = { tour, top_n: Math.min(limit, 100), page: tennisPage };
251
260
  effectiveScope = 'world';
252
261
  title = `${tour} singles rankings`;
253
262
  }
@@ -355,16 +364,36 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
355
364
  return rest;
356
365
  });
357
366
  }
367
+ const obj = asRecord(res.data);
368
+ const upstreamMeta = asRecord(obj?.meta) ?? {};
369
+ /**
370
+ * `total` is the size of the published table, NOT the size of this page.
371
+ *
372
+ * This read `total: allRows.length` — the rows that happened to arrive — so
373
+ * a tennis table of 150 ranked players reported total:5, hasMore:false and
374
+ * looked complete and unpaginated. The API now returns the real figure
375
+ * (total/ranked_players/total_pages/has_next); this prefers it and only
376
+ * falls back to the received count when the route does not state one.
377
+ *
378
+ * These are read BEFORE the pagination block, because that block had the
379
+ * identical bug: it published total: allRows.length too, so fixing only the
380
+ * data block left pagination.total disagreeing with data.total on the same
381
+ * response. Two counts, one response, only one of them true.
382
+ */
383
+ const upstreamTotal = typeof obj?.total === 'number' ? obj.total : null;
384
+ const rankedPlayers = typeof obj?.ranked_players === 'number' ? obj.ranked_players : null;
385
+ const totalPages = typeof obj?.total_pages === 'number' ? obj.total_pages : null;
386
+ const upstreamHasNext = typeof obj?.has_next === 'boolean' ? obj.has_next : null;
387
+ const effectiveTotal = upstreamTotal ?? allRows.length;
388
+ const effectiveHasMore = upstreamHasNext ?? offset + rows.length < effectiveTotal;
358
389
  const standingsPagination = {
359
390
  limit,
360
391
  offset,
361
- total: allRows.length,
362
- hasMore: offset + rows.length < allRows.length,
363
- nextCursor: offset + rows.length < allRows.length ? String(offset + rows.length) : null,
392
+ total: effectiveTotal,
393
+ hasMore: effectiveHasMore,
394
+ nextCursor: effectiveHasMore ? String(offset + rows.length) : null,
364
395
  prevCursor: offset > 0 ? String(Math.max(0, offset - limit)) : null,
365
396
  };
366
- const obj = asRecord(res.data);
367
- const upstreamMeta = asRecord(obj?.meta) ?? {};
368
397
  const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
369
398
  const warnings = [];
370
399
  const pushWarning = (value) => {
@@ -405,8 +434,16 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
405
434
  season: season ?? null,
406
435
  stage: stage ?? null,
407
436
  rows,
408
- total: allRows.length,
409
- hasMore: allRows.length > rows.length,
437
+ total: effectiveTotal,
438
+ hasMore: effectiveHasMore,
439
+ ...(game === 'tennis'
440
+ ? {
441
+ // How deep the published list is, independent of this page.
442
+ rankedPlayers: rankedPlayers ?? effectiveTotal,
443
+ totalPages,
444
+ page: tennisPage,
445
+ }
446
+ : {}),
410
447
  ...(game === 'tennis'
411
448
  ? {
412
449
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cito-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Standalone MCP server for the Cito esports and sports API — 42 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
5
5
  "type": "module",
6
6
  "bin": {