cito-mcp 0.4.1 → 0.4.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.
- package/README.md +13 -0
- package/dist/tools/insight.js +31 -3
- package/dist/tools/leaderboard.js +18 -2
- package/dist/tools/odds.js +60 -2
- package/dist/tools/resolve.js +62 -9
- package/dist/tools/standings.js +45 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -525,6 +525,19 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
|
|
|
525
525
|
|
|
526
526
|
## Changelog (summary)
|
|
527
527
|
|
|
528
|
+
### 0.4.2
|
|
529
|
+
|
|
530
|
+
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.
|
|
531
|
+
|
|
532
|
+
- **`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`.
|
|
533
|
+
- **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.
|
|
534
|
+
- **`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.
|
|
535
|
+
- **`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`.
|
|
536
|
+
- **`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.
|
|
537
|
+
- **`/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).
|
|
538
|
+
- **`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**.
|
|
539
|
+
- **`bestOf`** now reads `best_of`, the only spelling the tennis route emits.
|
|
540
|
+
|
|
528
541
|
### 0.4.1
|
|
529
542
|
|
|
530
543
|
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.
|
package/dist/tools/insight.js
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
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 ||
|
|
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:
|
|
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:
|
|
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
|
},
|
package/dist/tools/odds.js
CHANGED
|
@@ -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
|
|
283
|
-
rateLimit
|
|
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
|
},
|
package/dist/tools/resolve.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,49 @@ 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 first row the upstream returned 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. Exactly one row is protected, and only when a query was given, so
|
|
901
|
+
* the floor still removes all the padding it was added for.
|
|
902
|
+
*/
|
|
903
|
+
const protectedRow = q && items.length > 0 ? items[0] : null;
|
|
904
|
+
const protectedKey = protectedRow ? `${protectedRow.game}:${protectedRow.type}:${protectedRow.id}` : null;
|
|
871
905
|
if (q) {
|
|
872
906
|
// Publish the score on every row, not just the ones the scored branch
|
|
873
907
|
// produced, so the floor is visible rather than mysterious.
|
|
874
|
-
items = items.map((it) =>
|
|
908
|
+
items = items.map((it) => {
|
|
909
|
+
const key = `${it.game}:${it.type}:${it.id}`;
|
|
910
|
+
const isProtected = protectedKey !== null && key === protectedKey;
|
|
911
|
+
const raw = scoreOf(it);
|
|
912
|
+
return {
|
|
913
|
+
...it,
|
|
914
|
+
meta: {
|
|
915
|
+
...(it.meta ?? {}),
|
|
916
|
+
score: raw,
|
|
917
|
+
...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'upstream-first' } : {}),
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'upstream-first';
|
|
875
922
|
droppedBelowFloor = items
|
|
876
|
-
.filter((it) => it
|
|
923
|
+
.filter((it) => !keep(it))
|
|
877
924
|
.map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
|
|
878
|
-
if (droppedBelowFloor.length)
|
|
879
|
-
items = items.filter(
|
|
880
|
-
}
|
|
925
|
+
if (droppedBelowFloor.length)
|
|
926
|
+
items = items.filter(keep);
|
|
881
927
|
}
|
|
928
|
+
// An explicit (possibly rank-0) alias match beats a higher-scoring substring
|
|
929
|
+
// collision: the upstream's own top hit is the answer to a query we cannot
|
|
930
|
+
// score. This is what makes "Nole" resolve to Djokovic instead of M Canoles.
|
|
882
931
|
if (q && !preserveUpstreamOrder) {
|
|
883
|
-
items.sort((a, b) =>
|
|
932
|
+
items.sort((a, b) => {
|
|
933
|
+
const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
|
|
934
|
+
const bm = b.meta?.matchedBy === 'upstream-first' ? 1 : 0;
|
|
935
|
+
return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
|
|
936
|
+
});
|
|
884
937
|
}
|
|
885
938
|
// pagination over ranked result set
|
|
886
939
|
const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
|
package/dist/tools/standings.js
CHANGED
|
@@ -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:
|
|
362
|
-
hasMore:
|
|
363
|
-
nextCursor:
|
|
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:
|
|
409
|
-
hasMore:
|
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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": {
|