cito-mcp 0.4.0 → 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 +48 -1
- package/dist/tools/insight.js +126 -7
- package/dist/tools/leaderboard.js +18 -2
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +6 -6
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +122 -20
- package/dist/tools/player.js +88 -7
- package/dist/tools/rankings.js +75 -10
- package/dist/tools/resolve.js +121 -8
- package/dist/tools/schedule.js +101 -12
- package/dist/tools/standings.js +63 -10
- package/dist/tools/tournaments.js +70 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,19 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
|
|
|
42
42
|
|
|
43
43
|
**MCP is design-time / agent assistance.** Ship production backends with Cito REST + the user’s API key. Do not put multi-tenant traffic through this process.
|
|
44
44
|
|
|
45
|
+
### Honest gaps, in-band
|
|
46
|
+
|
|
47
|
+
Where the source data is partial, the tool says so instead of shipping a plausible-looking wrong value:
|
|
48
|
+
|
|
49
|
+
| Situation | What the tool returns |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| The two ranking lists compared by `rankings_movers` are not a week apart | `gapDays` + `comparisonWindow` (`"12-week gap"`), `comparisonsAreWeekly: false`, and a warning naming both dates |
|
|
52
|
+
| A tennis tournament draw is missing matches | `draw.shortRounds` (`["R128 45/64"]`), `draw.missingRounds`, `draw.complete` |
|
|
53
|
+
| A stats section the endpoint does not publish | `player_stats.unavailable` names the section and why, rather than a block of nulls |
|
|
54
|
+
| No bookmaker is quoting a match | `oddsAvailable: false` plus a note that it is a coverage gap, not an error |
|
|
55
|
+
| The odds feed leaves `match_id` null | `joinKey` (`{kind:"oddsEventId", …}`) and `playerIds`, so the row is still joinable |
|
|
56
|
+
| `search_entities` results that do not match at all | dropped, counted under `data.relevance.droppedBelowFloor`, with a warning naming them |
|
|
57
|
+
|
|
45
58
|
---
|
|
46
59
|
|
|
47
60
|
## Install
|
|
@@ -167,7 +180,7 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
|
|
|
167
180
|
| `head_to_head` | Composed H2H (no first-class REST H2H) | Rivalry / series record; preview context | Single-side form only; live scores; standings | `game`, `sideA`, `sideB`, `entityType?`, `limit?`, `from?`, `to?` |
|
|
168
181
|
| `standings` | League/event tables or world/division rankings | Playoff picture; UFC rankings; CDL / CS2 tables | Team form; live scores; match recaps | `game`, `scope?`, `leagueId?`, `tournamentId?`, `eventId?`, `season?`, `stage?`, `division?`, `limit?` |
|
|
169
182
|
| `match_preview` | Pre-match briefing: sides, rosters/form, H2H stub | Upcoming deep link; pick’ems; preview cards | Completed recaps; deep live state | `game`, `matchId` **or** (`teamA` + `teamB`), `eventId?`, `includeH2H?`, `includeRosters?`, `recentLimit?` |
|
|
170
|
-
| `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
|
|
183
|
+
| `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings. Tennis returns the draw bracket with per-round completeness | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?`, `drawMaxMatches?` |
|
|
171
184
|
| `list_routes` | Index of raw REST routes from the live OpenAPI spec (method, path, summary, tag) | Finding a long-tail path before `call_api`; checking an endpoint exists | A curated tool covers the outcome | `game?`, `q?`, `limit?` |
|
|
172
185
|
| `call_api` | Allowlisted raw REST (`data.raw`) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | `path`, `method?`, `queryJson?`, `bodyJson?` |
|
|
173
186
|
|
|
@@ -199,6 +212,11 @@ When a key is `null` the image does not exist for that entity; when the whole
|
|
|
199
212
|
`images` object is absent, upstream sent nothing for that side. The object is
|
|
200
213
|
never partially shaped — if any image exists, all four keys are present.
|
|
201
214
|
|
|
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.
|
|
219
|
+
|
|
202
220
|
### Games
|
|
203
221
|
|
|
204
222
|
| Game | Depth | Notes |
|
|
@@ -507,6 +525,35 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
|
|
|
507
525
|
|
|
508
526
|
## Changelog (summary)
|
|
509
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
|
+
|
|
541
|
+
### 0.4.1
|
|
542
|
+
|
|
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.
|
|
544
|
+
|
|
545
|
+
- **`match_summary` tennis scoreline was always `- : -`.** `/tennis/matches/{id}` puts no `sets_won` on its player objects, so the sets-won score was null for every completed match while the same payload held the real score in `score` and `sets[]`. Sets won are now derived (explicit → `winner_sets_won` → `sets[]` → `score` string), `score.detail` carries the game score, and a set's `completed` is inferred when the archive omits `is_completed`. The winner-oriented `winner_games`/`loser_games` pair is deliberately not used as a side score. `bestOf` now reads `best_of`.
|
|
546
|
+
- **`match_details` denied tennis odds existed.** The `odds` section was gated to UFC; the same match returned FanDuel/Matchbook prices from `tennis_odds`. Both surfaces now share one projection (`summarizeTennisOdds`), and `playerStats` (previously always `null` for tennis) is filled from `/tennis/matches/{id}/stats`.
|
|
547
|
+
- **`player_stats.bySurface` was always four nulls.** The endpoint publishes one object per surface and the projector coerced each to a number. Real per-surface blocks, `servingStats`, and an explicit `unavailable` map for sections the endpoint does not publish.
|
|
548
|
+
- **`limit` was ignored** by `player_rankings_history` (asked 4, got 342) and by `/tennis/matches/completed`. Both are bounded locally now, with `*Returned` versus `*Count` so the day total is never confused with the page.
|
|
549
|
+
- **`tournaments level="WTA 1000"` returned ATP Masters events.** The API's level reverse-map is not tour-aware (`_TIER_WTA` maps both `PM` and `M` to "WTA 1000"), so the tool now sends and enforces the tour the level name states, and drops + counts contradicting rows. Fixed at the source too — the same widening also leaked `WTA 125`, `Challenger`, `Davis Cup` and `Billie Jean King Cup`.
|
|
550
|
+
- **`rankings_movers` called an 84-day gap "week-over-week".** `gapDays`/`comparisonWindow`/`comparisonsAreWeekly` now state the real interval, with a warning. `standings` reports the tennis `ranking_date` as `updatedAt` instead of `null`.
|
|
551
|
+
- **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
|
|
552
|
+
- **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
|
|
553
|
+
- **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
|
|
554
|
+
- **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
|
|
555
|
+
- **`search_entities` had no relevance floor**: `q=Sinner` returned eight unrelated names. Rows that match nothing are dropped and reported, and a one- or two-character fragment no longer counts as a match.
|
|
556
|
+
|
|
510
557
|
### 0.2.4
|
|
511
558
|
|
|
512
559
|
- **UFC projection hardening** (offline-tested):
|
package/dist/tools/insight.js
CHANGED
|
@@ -811,8 +811,14 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
811
811
|
limit: limitSchema({
|
|
812
812
|
default: 20,
|
|
813
813
|
max: 50,
|
|
814
|
-
description: 'Max bouts/matches on the card (default 20, max 50).',
|
|
814
|
+
description: 'Max bouts/matches on the card (default 20, max 50). For a tennis draw this bounds the flat `bouts` list; the bracket in `draw.rounds` is returned whole by default, because a truncated bracket is not a bracket. Use drawMaxMatches to bound it.',
|
|
815
815
|
}),
|
|
816
|
+
drawMaxMatches: {
|
|
817
|
+
type: 'integer',
|
|
818
|
+
minimum: 1,
|
|
819
|
+
maximum: 500,
|
|
820
|
+
description: 'Tennis only. Cap the total matches carried in draw.rounds. The round list is filled from the business end backwards (Final, SF, QF, …) so a small cap keeps the decisive rounds and drops the early ones; draw.truncated and draw.omitted report what happened. Omit for the complete bracket.',
|
|
821
|
+
},
|
|
816
822
|
},
|
|
817
823
|
},
|
|
818
824
|
handler: async (args, ctx) => {
|
|
@@ -835,6 +841,9 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
835
841
|
const includeBouts = args.includeMatches !== false;
|
|
836
842
|
const includeStandings = args.includeStandings === true;
|
|
837
843
|
const limit = clampInt(args.limit, 20, 1, 50);
|
|
844
|
+
const drawMaxMatches = typeof args.drawMaxMatches === 'number' && Number.isFinite(args.drawMaxMatches)
|
|
845
|
+
? Math.max(1, Math.min(500, Math.trunc(args.drawMaxMatches)))
|
|
846
|
+
: null;
|
|
838
847
|
if (!eventKey && !q) {
|
|
839
848
|
return errorEnvelope({
|
|
840
849
|
code: 'VALIDATION',
|
|
@@ -873,10 +882,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
873
882
|
});
|
|
874
883
|
}
|
|
875
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
|
+
*/
|
|
876
896
|
let event = {
|
|
877
897
|
id: eventKey,
|
|
878
898
|
slug: eventKey,
|
|
879
|
-
name: q ||
|
|
899
|
+
name: q || null,
|
|
900
|
+
resolved: false,
|
|
880
901
|
game,
|
|
881
902
|
};
|
|
882
903
|
let bouts = [];
|
|
@@ -885,6 +906,33 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
885
906
|
// upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
|
|
886
907
|
// the tournament tree rather than one flat, order-dependent match list.
|
|
887
908
|
let drawRounds = null;
|
|
909
|
+
/**
|
|
910
|
+
* Draw completeness. A bracket is only useful if the caller knows whether it
|
|
911
|
+
* is whole: atp_2026_560 returns R32 15/16, R64 31/32 and R128 45/64 (the
|
|
912
|
+
* archived matches it actually holds), and the Final round is absent until
|
|
913
|
+
* it is played. The old response gave no signal at all, so a partial
|
|
914
|
+
* bracket silently looked like a complete one and a missing round looked
|
|
915
|
+
* like a missing feature.
|
|
916
|
+
*/
|
|
917
|
+
let drawSummary = null;
|
|
918
|
+
/** Matches a round of this code must contain, or null when unrecognised. */
|
|
919
|
+
const expectedMatchesForRound = (code, drawSize) => {
|
|
920
|
+
const c = (code ?? '').toUpperCase();
|
|
921
|
+
const rMatch = /^R(\d+)$/.exec(c);
|
|
922
|
+
if (rMatch)
|
|
923
|
+
return Math.max(1, Math.floor(Number(rMatch[1]) / 2));
|
|
924
|
+
if (c === 'F' || c === 'FINAL')
|
|
925
|
+
return 1;
|
|
926
|
+
if (c === 'SF')
|
|
927
|
+
return 2;
|
|
928
|
+
if (c === 'QF')
|
|
929
|
+
return 4;
|
|
930
|
+
if (c === 'BR' || c === 'RR')
|
|
931
|
+
return null; // round robin has no fixed size
|
|
932
|
+
if (drawSize && drawSize > 0 && c.startsWith('Q'))
|
|
933
|
+
return null;
|
|
934
|
+
return null;
|
|
935
|
+
};
|
|
888
936
|
if (game === 'ufc') {
|
|
889
937
|
const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
|
|
890
938
|
upstreamCalls += 1;
|
|
@@ -1272,7 +1320,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1272
1320
|
...event,
|
|
1273
1321
|
id: pickString(d.id) ?? eventKey,
|
|
1274
1322
|
slug: pickString(d.id) ?? eventKey,
|
|
1275
|
-
|
|
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,
|
|
1276
1328
|
tour: pickString(d.tour) ?? null,
|
|
1277
1329
|
level: pickString(d.level) ?? null,
|
|
1278
1330
|
tier: pickString(d.tier) ?? null,
|
|
@@ -1298,12 +1350,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1298
1350
|
if (draw.ok) {
|
|
1299
1351
|
const dd = asRecord(unwrapPayload(draw.data)) ?? {};
|
|
1300
1352
|
const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
|
|
1353
|
+
const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
|
|
1301
1354
|
const flat = [];
|
|
1355
|
+
// Fill the bracket from the front (the upstream orders rounds from the
|
|
1356
|
+
// business end backwards: SF, QF, R16, … R128), so a cap keeps the
|
|
1357
|
+
// decisive rounds and drops the early ones rather than the reverse.
|
|
1358
|
+
let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
|
|
1359
|
+
let omittedMatches = 0;
|
|
1302
1360
|
drawRounds = rawRounds.map((rr) => {
|
|
1303
1361
|
const rec = asRecord(rr) ?? {};
|
|
1304
1362
|
const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
|
|
1305
1363
|
const roundName = pickString(rec.round_name, rec.name, rec.round) ?? roundCode;
|
|
1306
|
-
const
|
|
1364
|
+
const all = Array.isArray(rec.matches) ? rec.matches : [];
|
|
1365
|
+
const kept = all.slice(0, Math.max(0, budget));
|
|
1366
|
+
omittedMatches += all.length - kept.length;
|
|
1367
|
+
budget -= kept.length;
|
|
1368
|
+
const matches = kept.map((mm) => {
|
|
1307
1369
|
const mr = asRecord(mm) ?? {};
|
|
1308
1370
|
const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
|
|
1309
1371
|
flat.push(row);
|
|
@@ -1318,11 +1380,56 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1318
1380
|
nextMatchId: pickString(mr.next_match_id) ?? null,
|
|
1319
1381
|
}, 'tennis');
|
|
1320
1382
|
});
|
|
1321
|
-
return {
|
|
1383
|
+
return {
|
|
1384
|
+
roundCode,
|
|
1385
|
+
roundName,
|
|
1386
|
+
matches,
|
|
1387
|
+
returned: all.length,
|
|
1388
|
+
expected: expectedMatchesForRound(roundCode, drawSize),
|
|
1389
|
+
complete: (() => {
|
|
1390
|
+
const expected = expectedMatchesForRound(roundCode, drawSize);
|
|
1391
|
+
return expected === null ? null : all.length >= expected;
|
|
1392
|
+
})(),
|
|
1393
|
+
};
|
|
1322
1394
|
});
|
|
1323
1395
|
bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
|
|
1324
1396
|
if (!bouts.length)
|
|
1325
1397
|
warnings.push('No draw yet: the tournament has no archived results');
|
|
1398
|
+
const expectedTotal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
|
|
1399
|
+
const returnedTotal = drawRounds.reduce((sum, r) => sum + r.returned, 0);
|
|
1400
|
+
const shortRounds = drawRounds
|
|
1401
|
+
.filter((r) => r.complete === false)
|
|
1402
|
+
.map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
|
|
1403
|
+
// A round of 128 draw must have a Final. Its absence is not an error
|
|
1404
|
+
// (the final may simply not be played yet) but it IS the difference
|
|
1405
|
+
// between "in progress" and "the API lost a round".
|
|
1406
|
+
const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
|
|
1407
|
+
const missingRounds = ['F', 'SF', 'QF']
|
|
1408
|
+
.filter((c) => !haveCodes.has(c))
|
|
1409
|
+
.filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
|
|
1410
|
+
drawSummary = {
|
|
1411
|
+
drawSize,
|
|
1412
|
+
// NOT `rounds`: that key holds the round ARRAY on the same object,
|
|
1413
|
+
// and spreading a count over it replaced the bracket with a number.
|
|
1414
|
+
roundCount: drawRounds.length,
|
|
1415
|
+
matchesReturned: returnedTotal,
|
|
1416
|
+
matchesExpected: expectedTotal || null,
|
|
1417
|
+
complete: expectedTotal > 0 ? returnedTotal >= expectedTotal : null,
|
|
1418
|
+
shortRounds,
|
|
1419
|
+
missingRounds,
|
|
1420
|
+
totalMatchesInCard: flat.length,
|
|
1421
|
+
returnedInBouts: bouts.length,
|
|
1422
|
+
truncated: omittedMatches > 0,
|
|
1423
|
+
omittedMatches,
|
|
1424
|
+
maxMatches: drawMaxMatches,
|
|
1425
|
+
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.',
|
|
1426
|
+
};
|
|
1427
|
+
if (shortRounds.length) {
|
|
1428
|
+
warnings.push(`Draw is partial: ${shortRounds.join(', ')}. The archive does not hold every match of this tournament yet.`);
|
|
1429
|
+
}
|
|
1430
|
+
if (omittedMatches > 0) {
|
|
1431
|
+
warnings.push(`drawMaxMatches=${drawMaxMatches} dropped ${omittedMatches} early-round match(es) from draw.rounds; raise or omit it for the complete bracket.`);
|
|
1432
|
+
}
|
|
1326
1433
|
}
|
|
1327
1434
|
else {
|
|
1328
1435
|
partial.push(partialFromRejection('bouts', {
|
|
@@ -1355,7 +1462,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1355
1462
|
message: `dota tournament HTTP ${detail.status}`,
|
|
1356
1463
|
httpStatus: detail.status,
|
|
1357
1464
|
}));
|
|
1358
|
-
event = { id: eventKey, slug: eventKey, name: q ||
|
|
1465
|
+
event = { id: eventKey, slug: eventKey, name: q || null, resolved: false, game };
|
|
1359
1466
|
}
|
|
1360
1467
|
if (includeBouts) {
|
|
1361
1468
|
const matchesRes = await fetchJson(ctx, '/dota2/matches/upcoming', { query: { limit } });
|
|
@@ -1390,6 +1497,18 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1390
1497
|
}));
|
|
1391
1498
|
}
|
|
1392
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
|
+
}
|
|
1393
1512
|
return successEnvelope({
|
|
1394
1513
|
source: 'event_card',
|
|
1395
1514
|
game,
|
|
@@ -1414,7 +1533,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1414
1533
|
// (Q1..R128, QF, SF, F) and each match's score/bracket progression —
|
|
1415
1534
|
// this is what makes a bracket renderable; `bouts` above is the flat
|
|
1416
1535
|
// list every other game already gets.
|
|
1417
|
-
...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds } : null } : {}),
|
|
1536
|
+
...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds, ...(drawSummary ?? {}) } : null } : {}),
|
|
1418
1537
|
standings: includeStandings ? standingsSnippet : null,
|
|
1419
1538
|
nextSteps: [
|
|
1420
1539
|
'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
|
|
@@ -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/match.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
|
-
import { normalizeMatch, normalizeUfcMethod, presentSides } from './normalize.js';
|
|
6
|
+
import { normalizeMatch, normalizeUfcMethod, presentSides, tennisSetCompleted } from './normalize.js';
|
|
7
|
+
import { summarizeTennisOdds } from './odds.js';
|
|
7
8
|
import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
|
|
8
9
|
async function getSection(ctx, path, query) {
|
|
9
10
|
return fetchJson(ctx, path, { query });
|
|
@@ -20,7 +21,7 @@ export function parseBestOf(r) {
|
|
|
20
21
|
const n = Number(v);
|
|
21
22
|
return Number.isFinite(n) && [1, 3, 5, 7, 9].includes(n) ? n : null;
|
|
22
23
|
};
|
|
23
|
-
const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo);
|
|
24
|
+
const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo) ?? fromNumber(r.best_of);
|
|
24
25
|
if (direct)
|
|
25
26
|
return direct;
|
|
26
27
|
const strat = r.strategy;
|
|
@@ -61,6 +62,12 @@ function matchCore(game, matchId, raw) {
|
|
|
61
62
|
const scoreline = m.team1 || m.team2
|
|
62
63
|
? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
|
|
63
64
|
: null;
|
|
65
|
+
/**
|
|
66
|
+
* Tennis: `score` is the set-by-set game score ("4-6 6-3 6-3 7-5"), which is
|
|
67
|
+
* a DIFFERENT fact from the sets-won scoreline. "3 : 1" is the result; it
|
|
68
|
+
* cannot distinguish 6-0 6-0 from three tiebreaks. Both are carried.
|
|
69
|
+
*/
|
|
70
|
+
const scoreDetail = game === 'tennis' ? (pickString(r.score, r.score_raw) ?? null) : null;
|
|
64
71
|
const core = {
|
|
65
72
|
matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
|
|
66
73
|
game,
|
|
@@ -73,6 +80,7 @@ function matchCore(game, matchId, raw) {
|
|
|
73
80
|
score: {
|
|
74
81
|
team1: s1,
|
|
75
82
|
team2: s2,
|
|
83
|
+
...(scoreDetail ? { detail: scoreDetail } : {}),
|
|
76
84
|
},
|
|
77
85
|
team1: m.team1,
|
|
78
86
|
team2: m.team2,
|
|
@@ -385,7 +393,10 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
385
393
|
player1Games: p1g,
|
|
386
394
|
player2Games: p2g,
|
|
387
395
|
tiebreak: sr.tiebreak ?? null,
|
|
388
|
-
|
|
396
|
+
// is_completed is a LIVE-route field. The archive route omits it,
|
|
397
|
+
// so every set of a finished match read completed:false. Fall
|
|
398
|
+
// back to the games, which always decide whether a set is over.
|
|
399
|
+
completed: tennisSetCompleted(sr),
|
|
389
400
|
};
|
|
390
401
|
});
|
|
391
402
|
return;
|
|
@@ -420,6 +431,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
420
431
|
display: `${match.team1?.name ?? '?'} ${match.score.team1 ?? '-'} : ${match.score.team2 ?? '-'} ${match.team2?.name ?? '?'}`,
|
|
421
432
|
team1: match.score.team1,
|
|
422
433
|
team2: match.score.team2,
|
|
434
|
+
...(game === 'tennis' && match.score.detail ? { detail: match.score.detail } : {}),
|
|
423
435
|
}, game);
|
|
424
436
|
if (view === 'summary' && playerPerformances.length > 10) {
|
|
425
437
|
playerPerformances = playerPerformances.slice(0, 10);
|
|
@@ -540,6 +552,52 @@ export function summarizeUfcOdds(data) {
|
|
|
540
552
|
fullBookPath: '/ufc/bouts/{boutId}/odds',
|
|
541
553
|
};
|
|
542
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Tennis per-match stats projection.
|
|
557
|
+
*
|
|
558
|
+
* `/tennis/matches/{id}/stats` does NOT return a row per player the way every
|
|
559
|
+
* other game's player-stats route does. It returns
|
|
560
|
+
* `{ stats: { winner: {...}, loser: {...} }, sets: [...], score }` — two objects
|
|
561
|
+
* keyed by OUTCOME. `match_details sections:["playerStats"]` had no tennis arm
|
|
562
|
+
* at all, so it answered `playerStats: null` while `match_summary` was filling
|
|
563
|
+
* `playerPerformances` from the same route.
|
|
564
|
+
*
|
|
565
|
+
* The sides stay keyed by outcome rather than being mislabelled as player1 /
|
|
566
|
+
* player2, because the payload carries no names and the ordering is not
|
|
567
|
+
* guaranteed to match the base row. `match.player1.is_winner` on the base
|
|
568
|
+
* section is what maps a name onto these numbers, and `sidesKeyedBy` says so.
|
|
569
|
+
*/
|
|
570
|
+
export function summarizeTennisMatchStats(data) {
|
|
571
|
+
const root = asRecord(data) ?? {};
|
|
572
|
+
const payload = asRecord(root.data) ?? root;
|
|
573
|
+
const stats = asRecord(payload.stats);
|
|
574
|
+
const sets = Array.isArray(payload.sets) ? payload.sets : [];
|
|
575
|
+
const toNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
576
|
+
return {
|
|
577
|
+
matchId: pickString(payload.match_id) ?? null,
|
|
578
|
+
score: pickString(payload.score) ?? null,
|
|
579
|
+
sidesKeyedBy: 'outcome',
|
|
580
|
+
players: stats
|
|
581
|
+
? {
|
|
582
|
+
winner: asRecord(stats.winner) ?? null,
|
|
583
|
+
loser: asRecord(stats.loser) ?? null,
|
|
584
|
+
}
|
|
585
|
+
: null,
|
|
586
|
+
sets: sets.map((s) => {
|
|
587
|
+
const sr = asRecord(s) ?? {};
|
|
588
|
+
return {
|
|
589
|
+
setNumber: toNum(sr.set_num) ?? toNum(sr.set_number),
|
|
590
|
+
winnerGames: toNum(sr.winner_games),
|
|
591
|
+
loserGames: toNum(sr.loser_games),
|
|
592
|
+
player1Games: toNum(sr.player1_games),
|
|
593
|
+
player2Games: toNum(sr.player2_games),
|
|
594
|
+
tiebreak: sr.tiebreak ?? null,
|
|
595
|
+
completed: tennisSetCompleted(sr),
|
|
596
|
+
};
|
|
597
|
+
}),
|
|
598
|
+
rawPath: '/tennis/matches/{matchId}/stats',
|
|
599
|
+
};
|
|
600
|
+
}
|
|
543
601
|
export const matchDetails = {
|
|
544
602
|
name: 'match_details',
|
|
545
603
|
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
@@ -574,7 +632,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
574
632
|
type: 'string',
|
|
575
633
|
enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
|
|
576
634
|
},
|
|
577
|
-
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds"
|
|
635
|
+
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted); tennis returns the same bookmaker/market/outcome projection that tennis_odds serves, including oddsAvailable=false when no book is quoting. Odds exist for UFC and tennis only.',
|
|
578
636
|
},
|
|
579
637
|
includeTimeline: boolSchema('Include timeline section (heavy).', false),
|
|
580
638
|
includeLiveState: boolSchema('Include live state/snapshots.', false),
|
|
@@ -726,6 +784,11 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
726
784
|
load('playerStats', `/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
|
|
727
785
|
if (game === 'ufc')
|
|
728
786
|
load('playerStats', `/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
|
|
787
|
+
// Tennis has a real per-match stats route; the section simply had no arm
|
|
788
|
+
// for it and shipped playerStats:null forever.
|
|
789
|
+
if (game === 'tennis') {
|
|
790
|
+
loadWith('playerStats', `/tennis/matches/${encodeURIComponent(matchId)}/stats`, summarizeTennisMatchStats);
|
|
791
|
+
}
|
|
729
792
|
}
|
|
730
793
|
if (wanted.has('gamesOrMaps')) {
|
|
731
794
|
if (game === 'lol')
|
|
@@ -772,10 +835,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
772
835
|
if (game === 'ufc') {
|
|
773
836
|
loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
|
|
774
837
|
}
|
|
838
|
+
else if (game === 'tennis') {
|
|
839
|
+
// The gate that produced "Odds not curated for tennis; UFC only today"
|
|
840
|
+
// was written before /tennis/odds/{id} existed. It does: the same match
|
|
841
|
+
// returned FanDuel 1.105 / Matchbook 1.13 from tennis_odds while this
|
|
842
|
+
// section denied odds existed. Both surfaces now share one projection.
|
|
843
|
+
loadWith('odds', `/tennis/odds/${encodeURIComponent(matchId)}`, summarizeTennisOdds);
|
|
844
|
+
}
|
|
775
845
|
else {
|
|
776
846
|
partial.push(partialFromRejection('odds', {
|
|
777
847
|
code: 'NOT_IMPLEMENTED',
|
|
778
|
-
message: `Odds not curated for ${game}
|
|
848
|
+
message: `Odds are not curated for ${game}. Available: UFC (/ufc/bouts/{id}/odds) and tennis (/tennis/odds/{id}).`,
|
|
779
849
|
}));
|
|
780
850
|
}
|
|
781
851
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -38,7 +38,7 @@ const TOOL_CATALOG = [
|
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
name: 'search_entities',
|
|
41
|
-
outcome: 'Browse/search entities with type filter and pagination',
|
|
41
|
+
outcome: 'Browse/search entities with type filter and pagination; when q is given, results below the relevance floor are dropped and reported under data.relevance',
|
|
42
42
|
parallelSafe: true,
|
|
43
43
|
games: [...PRIMARY_GAMES],
|
|
44
44
|
jobs: ['team_page', 'player_form'],
|
|
@@ -138,7 +138,7 @@ const TOOL_CATALOG = [
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
name: 'rankings_movers',
|
|
141
|
-
outcome: 'Tennis ranking
|
|
141
|
+
outcome: 'Tennis ranking climbers/fallers plus new entries and drop-outs, with gapDays/comparisonWindow stating the real interval between the two lists',
|
|
142
142
|
parallelSafe: true,
|
|
143
143
|
games: ['tennis'],
|
|
144
144
|
jobs: ['standings'],
|
|
@@ -168,7 +168,7 @@ const TOOL_CATALOG = [
|
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
name: 'player_stats',
|
|
171
|
-
outcome: 'Tennis career statistics: W/L, win%, titles,
|
|
171
|
+
outcome: 'Tennis career statistics: W/L, win%, titles, per-surface breakdown, serving stats, and named gaps',
|
|
172
172
|
parallelSafe: true,
|
|
173
173
|
games: ['tennis'],
|
|
174
174
|
jobs: ['player_form'],
|
|
@@ -188,7 +188,7 @@ const TOOL_CATALOG = [
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: 'tennis_odds',
|
|
191
|
-
outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match',
|
|
191
|
+
outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match; upcoming rows carry a joinKey and playerIds because the feed leaves match_id null',
|
|
192
192
|
parallelSafe: true,
|
|
193
193
|
games: ['tennis'],
|
|
194
194
|
jobs: ['preview', 'match_page', 'odds'],
|
|
@@ -198,7 +198,7 @@ const TOOL_CATALOG = [
|
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
name: 'tournaments',
|
|
201
|
-
outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country',
|
|
201
|
+
outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country; a level name that names a tour also fixes it',
|
|
202
202
|
parallelSafe: true,
|
|
203
203
|
games: ['tennis'],
|
|
204
204
|
jobs: ['preview', 'standings'],
|
|
@@ -208,7 +208,7 @@ const TOOL_CATALOG = [
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
name: 'tennis_schedule',
|
|
211
|
-
outcome: "One day of tennis: that day's schedule and/or completed matches",
|
|
211
|
+
outcome: "One day of tennis: that day's schedule and/or completed matches, with limit applied to each half and the two halves' overlap counted",
|
|
212
212
|
parallelSafe: true,
|
|
213
213
|
games: ['tennis'],
|
|
214
214
|
jobs: ['schedule', 'match_page'],
|