cito-mcp 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/client.js +39 -35
- package/dist/envelope.js +3 -13
- package/dist/http.js +0 -16
- package/dist/index.js +0 -39
- package/dist/install.js +0 -44
- package/dist/instructions.js +76 -80
- package/dist/scrub.js +111 -0
- package/dist/tools/cs2.js +1 -48
- package/dist/tools/index.js +0 -11
- package/dist/tools/insight.js +42 -174
- package/dist/tools/leaderboard.js +0 -21
- package/dist/tools/live.js +36 -127
- package/dist/tools/match.js +29 -184
- package/dist/tools/meta.js +7 -52
- package/dist/tools/normalize.js +6 -229
- package/dist/tools/odds.js +0 -66
- package/dist/tools/player.js +45 -179
- package/dist/tools/rankings.js +0 -38
- package/dist/tools/resolve.js +0 -136
- package/dist/tools/schedule.js +0 -55
- package/dist/tools/standings.js +21 -65
- package/dist/tools/team.js +2 -83
- package/dist/tools/tournaments.js +0 -37
- package/dist/tools/types.js +0 -17
- package/dist/version.js +0 -17
- package/package.json +22 -2
package/dist/tools/odds.js
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tennis_odds — the betting-odds surface for tennis (TENNIS-24).
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS
|
|
5
|
-
* The tennis API serves three odds routes, and NONE of them had a tool. Odds are
|
|
6
|
-
* a headline feature of the paid tiers and one of the most common reasons to buy
|
|
7
|
-
* a sports API, so a builder had to discover /tennis/odds/* through call_api or
|
|
8
|
-
* the docs. This closes that gap.
|
|
9
|
-
*
|
|
10
|
-
* Coverage is genuinely partial upstream, and the tool says so rather than
|
|
11
|
-
* returning an empty success: /tennis/odds/{id} answers with
|
|
12
|
-
* `coverage: { odds: false }` and an empty bookmakers[] for any match the feed
|
|
13
|
-
* has no prices for (verified against atp_2026_560, which returned exactly
|
|
14
|
-
* that). An agent that cannot tell "no odds exist for this match" from "the call
|
|
15
|
-
* failed" will invent a price, so the response distinguishes the two.
|
|
16
|
-
*/
|
|
17
1
|
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
18
2
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
19
3
|
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
20
|
-
/** A bookmaker row: { key, title, last_update, markets: [{ key, outcomes: [{ name, price }] }] }. */
|
|
21
4
|
function normalizeBookmaker(row) {
|
|
22
5
|
const r = asRecord(row) ?? {};
|
|
23
6
|
const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
|
|
@@ -26,7 +9,6 @@ function normalizeBookmaker(row) {
|
|
|
26
9
|
const oc = asRecord(o) ?? {};
|
|
27
10
|
return {
|
|
28
11
|
name: pickString(oc.name) ?? null,
|
|
29
|
-
// Decimal odds as the feed supplies them; never recomputed or implied.
|
|
30
12
|
price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
|
|
31
13
|
point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
|
|
32
14
|
};
|
|
@@ -34,8 +16,6 @@ function normalizeBookmaker(row) {
|
|
|
34
16
|
return {
|
|
35
17
|
market: pickString(m.key) ?? null,
|
|
36
18
|
outcomes,
|
|
37
|
-
// Implied probability from the prices actually returned, so a caller does
|
|
38
|
-
// not have to redo the arithmetic. Null when any price is missing.
|
|
39
19
|
impliedProbabilities: (() => {
|
|
40
20
|
const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
|
|
41
21
|
if (prices.length !== outcomes.length || prices.length === 0)
|
|
@@ -53,19 +33,8 @@ function normalizeBookmaker(row) {
|
|
|
53
33
|
markets,
|
|
54
34
|
};
|
|
55
35
|
}
|
|
56
|
-
/**
|
|
57
|
-
* The one tennis-odds projection, shared by `tennis_odds` and `match_details`.
|
|
58
|
-
*
|
|
59
|
-
* Both surfaces read the same REST payload, and before this the two disagreed:
|
|
60
|
-
* `tennis_odds scope=match` served FanDuel 1.105 / Matchbook 1.13 with implied
|
|
61
|
-
* probabilities while `match_details sections:["odds"]` answered
|
|
62
|
-
* NOT_IMPLEMENTED "UFC only today" for the same match id. A caller could not
|
|
63
|
-
* tell whether tennis odds existed. One shaper, one answer.
|
|
64
|
-
*/
|
|
65
36
|
export function summarizeTennisOdds(data) {
|
|
66
37
|
const root = asRecord(data) ?? {};
|
|
67
|
-
// fetchJson hands back the whole REST envelope; the unit tests feed the inner
|
|
68
|
-
// object directly. Accept both.
|
|
69
38
|
const payload = asRecord(root.data) ?? root;
|
|
70
39
|
const coverage = asRecord(payload.coverage) ?? {};
|
|
71
40
|
const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
|
|
@@ -74,11 +43,8 @@ export function summarizeTennisOdds(data) {
|
|
|
74
43
|
matchId: pickString(payload.match_id) ?? null,
|
|
75
44
|
oddsFormat: pickString(payload.odds_format) ?? 'decimal',
|
|
76
45
|
commenceTime: pickString(payload.commence_time) ?? null,
|
|
77
|
-
/** Upstream says the feed has since moved on; the prices are still real. */
|
|
78
46
|
stale: payload.stale === true,
|
|
79
47
|
bookmakers,
|
|
80
|
-
// Explicit, because an empty bookmakers[] is otherwise indistinguishable
|
|
81
|
-
// from a failed scrape.
|
|
82
48
|
oddsAvailable: hasOdds,
|
|
83
49
|
note: hasOdds
|
|
84
50
|
? null
|
|
@@ -216,21 +182,11 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
216
182
|
player2: { name: pickString(away.name) ?? null, id: awayId },
|
|
217
183
|
bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
|
|
218
184
|
live: r.live === true,
|
|
219
|
-
/**
|
|
220
|
-
* The feed does not join its odds events to matches: every row of
|
|
221
|
-
* /tennis/odds/upcoming carries match_id:null (verified on all 94
|
|
222
|
-
* rows), so a caller could not go odds -> match and the two halves of
|
|
223
|
-
* a betting UI could not be stitched together. matchId itself is left
|
|
224
|
-
* exactly as the feed states it — null stays null, because inventing
|
|
225
|
-
* one would be worse — and the join key that DOES resolve is named
|
|
226
|
-
* alongside it.
|
|
227
|
-
*/
|
|
228
185
|
joinKey: matchId
|
|
229
186
|
? { kind: 'matchId', matchId }
|
|
230
187
|
: oddsEventId
|
|
231
188
|
? { kind: 'oddsEventId', oddsEventId }
|
|
232
189
|
: null,
|
|
233
|
-
/** Player ids present on the row, which identify the fixture when matchId is null. */
|
|
234
190
|
playerIds: [homeId, awayId].filter((x) => Boolean(x)),
|
|
235
191
|
};
|
|
236
192
|
});
|
|
@@ -280,23 +236,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
280
236
|
});
|
|
281
237
|
}
|
|
282
238
|
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
239
|
let matchExists = null;
|
|
301
240
|
if (summary.oddsAvailable !== true) {
|
|
302
241
|
const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
|
|
@@ -304,7 +243,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
304
243
|
rateLimit = { ...rateLimit, ...probe.headers };
|
|
305
244
|
matchExists = probe.ok;
|
|
306
245
|
if (!matchExists) {
|
|
307
|
-
// Live ids live at /matches/live/{id} until the match archives.
|
|
308
246
|
const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
|
|
309
247
|
upstreamCalls += 1;
|
|
310
248
|
rateLimit = { ...rateLimit, ...liveProbe.headers };
|
|
@@ -339,12 +277,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
339
277
|
data: {
|
|
340
278
|
title: `Tennis ${scopeRaw} odds — ${matchId}`,
|
|
341
279
|
scope: scopeRaw,
|
|
342
|
-
// One projection, the same object match_details sections:["odds"]
|
|
343
|
-
// returns. See summarizeTennisOdds.
|
|
344
280
|
...summary,
|
|
345
281
|
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
282
|
matchExists,
|
|
349
283
|
},
|
|
350
284
|
});
|
package/dist/tools/player.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* player_profile
|
|
3
|
-
*/
|
|
4
1
|
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
2
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
3
|
import { normalizeMatch } from './normalize.js';
|
|
@@ -9,13 +6,7 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
9
6
|
const r = asRecord(raw) ?? {};
|
|
10
7
|
const id = pickString(r.id, r.playerId, r.lolPlayerId, r.codPlayerId, idHint, slugHint) ?? 'unknown';
|
|
11
8
|
const slug = pickString(r.slug, slugHint);
|
|
12
|
-
// currentIgn FIRST for LoL. The row carries currentIgn "Faker" and realName
|
|
13
|
-
// "Sanghyeok Lee" but no `name`, so this fell through every key to the id and
|
|
14
|
-
// served a raw UUID as the player's display name.
|
|
15
9
|
const name = pickString(r.name, r.currentIgn, r.ign, r.full_name, r.nickname, r.displayName, r.tag, r.realName, slug, id) ?? id;
|
|
16
|
-
// currentTeam is a STRING on the LoL row ("T1"), not an object, so asRecord
|
|
17
|
-
// returned null and the profile reported a team of "?" while the slug sat
|
|
18
|
-
// right there in currentTeamSlug.
|
|
19
10
|
const currentTeamName = pickString(r.currentTeam);
|
|
20
11
|
const currentTeamSlug = pickString(r.currentTeamSlug, r.teamSlug, r.orgSlug);
|
|
21
12
|
const team = asRecord(r.team) ??
|
|
@@ -40,40 +31,11 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
40
31
|
: null,
|
|
41
32
|
role: pickString(r.role, r.position) ?? null,
|
|
42
33
|
nationality: pickString(r.nationality, r.country) ?? null,
|
|
43
|
-
/**
|
|
44
|
-
* Always present, keys always present, null when the upstream has no photo.
|
|
45
|
-
*
|
|
46
|
-
* REST has carried headshot/body/proxied images on /ufc/fighters/{slug} all
|
|
47
|
-
* along; this shaper silently dropped them, so every agent concluded the
|
|
48
|
-
* product had no images and either shipped faceless UIs or N+1'd call_api
|
|
49
|
-
* per fighter to dig them out. Emitting explicit nulls is the point: silence
|
|
50
|
-
* reads as "not supported", null reads as "not available for this one".
|
|
51
|
-
*
|
|
52
|
-
* proxiedImageUrl is the one builders should prefer — it is served from our
|
|
53
|
-
* own domain, so it works from a browser without hotlink/CORS trouble.
|
|
54
|
-
*/
|
|
55
34
|
images: {
|
|
56
|
-
// Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
|
|
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.
|
|
61
35
|
headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
|
|
62
36
|
bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
|
|
63
37
|
imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
|
|
64
38
|
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
39
|
license: pickString(r.portrait_license) ?? null,
|
|
78
40
|
attribution: pickString(r.portrait_attribution) ?? null,
|
|
79
41
|
sourceUrl: pickString(r.portrait_source_url) ?? null,
|
|
@@ -82,17 +44,17 @@ function identityFrom(game, raw, idHint, slugHint) {
|
|
|
82
44
|
}
|
|
83
45
|
export const playerProfile = {
|
|
84
46
|
name: 'player_profile',
|
|
85
|
-
description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
|
|
86
|
-
|
|
87
|
-
When to use:
|
|
88
|
-
- "How is X playing lately?"
|
|
89
|
-
- Player page scaffold; form inputs for previews
|
|
90
|
-
|
|
91
|
-
Prefer over: manual multi-call career/trends/matches via call_api.
|
|
92
|
-
|
|
93
|
-
Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
|
|
94
|
-
|
|
95
|
-
Parallel-safe: yes. Upstream cost: 2–5.
|
|
47
|
+
description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
|
|
48
|
+
|
|
49
|
+
When to use:
|
|
50
|
+
- "How is X playing lately?"
|
|
51
|
+
- Player page scaffold; form inputs for previews
|
|
52
|
+
|
|
53
|
+
Prefer over: manual multi-call career/trends/matches via call_api.
|
|
54
|
+
|
|
55
|
+
Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
|
|
56
|
+
|
|
57
|
+
Parallel-safe: yes. Upstream cost: 2–5.
|
|
96
58
|
Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
|
|
97
59
|
inputSchema: {
|
|
98
60
|
type: 'object',
|
|
@@ -172,7 +134,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
172
134
|
else if (game === 'cs2')
|
|
173
135
|
primaryPath = `/cs2/players/${encodeURIComponent(idOrSlug)}`;
|
|
174
136
|
else if (game === 'dota2')
|
|
175
|
-
primaryPath = `/dota2/players`;
|
|
137
|
+
primaryPath = `/dota2/players`;
|
|
176
138
|
else if (game === 'cod')
|
|
177
139
|
primaryPath = `/cod/players/${encodeURIComponent(idOrSlug)}`;
|
|
178
140
|
else if (game === 'ufc')
|
|
@@ -181,7 +143,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
181
143
|
primaryPath = `/tennis/players/${encodeURIComponent(idOrSlug)}`;
|
|
182
144
|
let playerRaw = null;
|
|
183
145
|
if (game === 'dota2') {
|
|
184
|
-
// Prefer radar as primary signal; try list filter
|
|
185
146
|
const list = await fetchJson(ctx, '/dota2/players', { query: { search: idOrSlug, limit: 5 } });
|
|
186
147
|
upstreamCalls += 1;
|
|
187
148
|
rateLimit = list.headers;
|
|
@@ -195,7 +156,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
195
156
|
}) ?? rows[0] ?? null;
|
|
196
157
|
}
|
|
197
158
|
if (!playerRaw) {
|
|
198
|
-
// still try radar with idOrSlug
|
|
199
159
|
playerRaw = { id: idOrSlug, slug: idOrSlug, name: idOrSlug };
|
|
200
160
|
}
|
|
201
161
|
}
|
|
@@ -235,14 +195,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
235
195
|
const rows = extractRows(res.data);
|
|
236
196
|
const first = asRecord(rows[0]);
|
|
237
197
|
if (first) {
|
|
238
|
-
// NEVER let team history overwrite the player row's own team.
|
|
239
|
-
// This endpoint returns showmatch and novelty orgs alongside the
|
|
240
|
-
// real one, all marked status "current" — for Faker the first row
|
|
241
|
-
// is literally "Faker", then "Captain Faker", then "KR with
|
|
242
|
-
// Influencers". Taking rows[0] reported the player as their own
|
|
243
|
-
// team. The player row's currentTeamSlug is the answer; history is
|
|
244
|
-
// only a fallback, and then only for a row that looks like a real
|
|
245
|
-
// club rather than the player's own name.
|
|
246
198
|
const rows2 = rows.map((row) => asRecord(row) ?? {});
|
|
247
199
|
const preferred = (player.team?.slug
|
|
248
200
|
? rows2.find((x) => pickString(x.orgSlug, x.slug) === player.team?.slug)
|
|
@@ -271,9 +223,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
271
223
|
})());
|
|
272
224
|
}
|
|
273
225
|
if (game === 'lol') {
|
|
274
|
-
// /lol/players/{id}/matches exists and returns the player's real match
|
|
275
|
-
// index; only tennis was wired to its equivalent, so every LoL profile
|
|
276
|
-
// shipped recentMatches: [].
|
|
277
226
|
tasks.push((async () => {
|
|
278
227
|
const res = await fetchJson(ctx, `/lol/players/${encodeURIComponent(idOrSlug)}/matches`, {
|
|
279
228
|
query: { limit: String(recentLimit) },
|
|
@@ -290,10 +239,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
290
239
|
.slice()
|
|
291
240
|
.sort((a, b) => startMs(b) - startMs(a))
|
|
292
241
|
.slice(0, recentLimit);
|
|
293
|
-
// The index carries team SLUGS and no names, so labels read
|
|
294
|
-
// "hle vs t1". Resolve the distinct slugs once — deduplicated and
|
|
295
|
-
// capped — rather than a lookup per match, and fall back to the
|
|
296
|
-
// slug when a lookup fails so a label is never blank.
|
|
297
242
|
const slugs = [
|
|
298
243
|
...new Set(picked.flatMap((row) => {
|
|
299
244
|
const r = asRecord(row) ?? {};
|
|
@@ -477,9 +422,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
477
422
|
if (res.ok) {
|
|
478
423
|
const metrics = unwrapPayload(res.data);
|
|
479
424
|
form = { summary: 'UFC fighter stats', trend: null, window: null, metrics };
|
|
480
|
-
// form.metrics and seasonStats were the same object twice, byte
|
|
481
|
-
// for byte. One copy. seasonStats stays for the games that have
|
|
482
|
-
// seasons; a fighter has a career, not a season.
|
|
483
425
|
}
|
|
484
426
|
else {
|
|
485
427
|
partial.push(partialFromRejection('form', {
|
|
@@ -490,7 +432,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
490
432
|
}
|
|
491
433
|
})());
|
|
492
434
|
tasks.push((async () => {
|
|
493
|
-
// Fight-by-fight history — /fights and /history are aliases on the API
|
|
494
435
|
const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/fights`, {
|
|
495
436
|
query: { limit: recentLimit },
|
|
496
437
|
});
|
|
@@ -501,24 +442,8 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
501
442
|
.slice(0, recentLimit)
|
|
502
443
|
.map((row) => {
|
|
503
444
|
const r = asRecord(row) ?? {};
|
|
504
|
-
// Merge the row's own corner fields (fighterSlug / opponent /
|
|
505
|
-
// outcome) over the nested bout: they are the only place the two
|
|
506
|
-
// sides exist on a history row, and without them every entry
|
|
507
|
-
// came back team1:null, team2:null with the division as label.
|
|
508
445
|
const bout = { ...(asRecord(r.bout) ?? {}), ...r };
|
|
509
|
-
// Never force 'completed'. A fighter's history endpoint also
|
|
510
|
-
// returns bouts that are booked but not yet fought, and forcing
|
|
511
|
-
// the status told agents that a future main event had already
|
|
512
|
-
// happened (Hernandez vs Rodrigues, ufc-12928: status
|
|
513
|
-
// 'confirmed', no method, no winner, event still scheduled,
|
|
514
|
-
// reported as completed with result null). Let normalizeMatch
|
|
515
|
-
// derive it from the result and the start time instead.
|
|
516
446
|
const m = normalizeMatch('ufc', bout);
|
|
517
|
-
// Keep the normalized result OBJECT (method, round, time,
|
|
518
|
-
// winnerSlug), the same shape match_preview and match_summary
|
|
519
|
-
// return. This used to overwrite it with the string "win",
|
|
520
|
-
// giving the profile a fourth result schema of its own. The
|
|
521
|
-
// fighter's-eye view lives on outcome instead.
|
|
522
447
|
return {
|
|
523
448
|
...m,
|
|
524
449
|
outcome: pickString(r.outcome)?.toLowerCase() ?? null,
|
|
@@ -528,7 +453,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
528
453
|
});
|
|
529
454
|
}
|
|
530
455
|
else {
|
|
531
|
-
// fallback history path
|
|
532
456
|
const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/history`, {
|
|
533
457
|
query: { limit: recentLimit },
|
|
534
458
|
});
|
|
@@ -540,7 +464,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
540
464
|
.map((row) => {
|
|
541
465
|
const r = asRecord(row) ?? {};
|
|
542
466
|
const bout = { ...(asRecord(r.bout) ?? {}), ...r };
|
|
543
|
-
// Same as above: derive, never assert. See the note there.
|
|
544
467
|
return normalizeMatch('ufc', bout);
|
|
545
468
|
});
|
|
546
469
|
}
|
|
@@ -573,10 +496,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
573
496
|
...(player.slug ? { slug: player.slug } : {}),
|
|
574
497
|
},
|
|
575
498
|
},
|
|
576
|
-
// Two rules, so a consumer can tell the difference. A field that can
|
|
577
|
-
// never apply to this game is OMITTED (a fighter has no team, no radar,
|
|
578
|
-
// no season). A field that applies but is unknown is NULL (division,
|
|
579
|
-
// flag). Sending team:null on every UFC profile forever said nothing.
|
|
580
499
|
data: game === 'ufc'
|
|
581
500
|
? {
|
|
582
501
|
player: (({ team, role, nationality, ...rest }) => { void team; void role; void nationality; return rest; })(player),
|
|
@@ -595,15 +514,6 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
595
514
|
});
|
|
596
515
|
},
|
|
597
516
|
};
|
|
598
|
-
/**
|
|
599
|
-
* player_form (TENNIS-12).
|
|
600
|
-
*
|
|
601
|
-
* Tennis-only: recent-match form for one player (GET /tennis/players/{id}/form).
|
|
602
|
-
* The upstream answers wins/losses/win_pct over the last N completed matches
|
|
603
|
-
* plus the per-match rows (result, opponent, score, surface) and the player's
|
|
604
|
-
* current rank + movement. This tool normalizes those rows and derives the
|
|
605
|
-
* current W/L streak from the leading run, newest first.
|
|
606
|
-
*/
|
|
607
517
|
const SURFACES = ['Hard', 'Clay', 'Grass'];
|
|
608
518
|
function parseFormSurface(args) {
|
|
609
519
|
const raw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
@@ -631,17 +541,17 @@ function normalizeFormMatch(row) {
|
|
|
631
541
|
}
|
|
632
542
|
export const playerForm = {
|
|
633
543
|
name: 'player_form',
|
|
634
|
-
description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
|
|
635
|
-
|
|
636
|
-
When to use:
|
|
637
|
-
- "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
|
|
638
|
-
|
|
639
|
-
Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
|
|
640
|
-
|
|
641
|
-
Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
|
|
642
|
-
|
|
643
|
-
Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
|
|
644
|
-
|
|
544
|
+
description: `Tennis player form: W/L record over the last N completed matches, current win/loss streak, and per-match rows (opponent, score, surface). Optional surface filter (Hard/Clay/Grass).
|
|
545
|
+
|
|
546
|
+
When to use:
|
|
547
|
+
- "How is X playing lately?"; current streak; surface-specific record (e.g. clay last 5).
|
|
548
|
+
|
|
549
|
+
Prefer over: player_profile (identity + career aggregates, no streak); raw form via call_api for agent-normalized rows.
|
|
550
|
+
|
|
551
|
+
Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
|
|
552
|
+
|
|
553
|
+
Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
|
|
554
|
+
|
|
645
555
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
646
556
|
inputSchema: {
|
|
647
557
|
type: 'object',
|
|
@@ -732,7 +642,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
732
642
|
const root = asRecord(res.data);
|
|
733
643
|
const body = asRecord(root?.data) ?? root ?? {};
|
|
734
644
|
const matches = (Array.isArray(body.matches) ? body.matches : []).map(normalizeFormMatch);
|
|
735
|
-
// Upstream rows arrive newest-first: the streak is the leading run.
|
|
736
645
|
let streakCount = 0;
|
|
737
646
|
let streakResult = null;
|
|
738
647
|
for (const m of matches) {
|
|
@@ -791,18 +700,18 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
791
700
|
};
|
|
792
701
|
export const playerMatches = {
|
|
793
702
|
name: 'player_matches',
|
|
794
|
-
description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
|
|
795
|
-
|
|
796
|
-
When to use:
|
|
797
|
-
- "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
|
|
798
|
-
|
|
799
|
-
Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
|
|
800
|
-
|
|
801
|
-
Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
|
|
802
|
-
|
|
803
|
-
Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
|
|
804
|
-
surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
|
|
805
|
-
|
|
703
|
+
description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
|
|
704
|
+
|
|
705
|
+
When to use:
|
|
706
|
+
- "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
|
|
707
|
+
|
|
708
|
+
Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
|
|
709
|
+
|
|
710
|
+
Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
|
|
711
|
+
|
|
712
|
+
Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
|
|
713
|
+
surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
|
|
714
|
+
|
|
806
715
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
807
716
|
inputSchema: {
|
|
808
717
|
type: 'object',
|
|
@@ -884,16 +793,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
884
793
|
const body = asRecord(root?.data) ?? root ?? {};
|
|
885
794
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
886
795
|
const allRows = Array.isArray(body.items) ? body.items : [];
|
|
887
|
-
/**
|
|
888
|
-
* Derive the player's display name from their own side of any match.
|
|
889
|
-
*
|
|
890
|
-
* The match-log payload carries no player_name at all (verified against the
|
|
891
|
-
* live route: top-level keys are just items/total/page/page_size/has_next/
|
|
892
|
-
* has_prev/success/total_pages), so the earlier version titled every log
|
|
893
|
-
* "<id> match log" and reported playerName: null. Each row does carry the
|
|
894
|
-
* winner and loser objects, so whichever side matches the requested id gives
|
|
895
|
-
* a real name with no extra upstream call.
|
|
896
|
-
*/
|
|
897
796
|
let derivedName = null;
|
|
898
797
|
for (const row of allRows) {
|
|
899
798
|
const r = asRecord(row) ?? {};
|
|
@@ -908,11 +807,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
908
807
|
if (derivedName)
|
|
909
808
|
break;
|
|
910
809
|
}
|
|
911
|
-
/**
|
|
912
|
-
* The route ignores ?limit= (verified: limit=5 returned 20), so the bound is
|
|
913
|
-
* applied here. total comes from the upstream header so callers still learn
|
|
914
|
-
* how many matches exist beyond this page.
|
|
915
|
-
*/
|
|
916
810
|
const total = typeof body.total === 'number' ? body.total : allRows.length;
|
|
917
811
|
const rows = allRows.slice(0, limit).map((row) => {
|
|
918
812
|
const r = asRecord(row) ?? {};
|
|
@@ -920,9 +814,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
920
814
|
const loser = asRecord(r.loser) ?? {};
|
|
921
815
|
const winnerId = pickString(r.winner_id, winner.id);
|
|
922
816
|
const lost = winnerId !== null && winnerId !== playerId;
|
|
923
|
-
// Opponent is whichever side is not this player. When the row's winner
|
|
924
|
-
// does not match the requested id the player lost, so the opponent is the
|
|
925
|
-
// winner.
|
|
926
817
|
const opponent = lost ? winner : loser;
|
|
927
818
|
return {
|
|
928
819
|
matchId: pickString(r.id, r.match_id) ?? null,
|
|
@@ -971,8 +862,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
971
862
|
tournamentFilter: tournamentId || null,
|
|
972
863
|
matches: rows,
|
|
973
864
|
total,
|
|
974
|
-
// Named explicitly because the bound is applied locally: the upstream
|
|
975
|
-
// route returns everything and ignores ?limit=.
|
|
976
865
|
returned: rows.length,
|
|
977
866
|
},
|
|
978
867
|
});
|
|
@@ -980,16 +869,16 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
980
869
|
};
|
|
981
870
|
export const playerStats = {
|
|
982
871
|
name: 'player_stats',
|
|
983
|
-
description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
|
|
984
|
-
|
|
985
|
-
When to use:
|
|
986
|
-
- "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
|
|
987
|
-
|
|
988
|
-
Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
|
|
989
|
-
|
|
990
|
-
Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
|
|
991
|
-
|
|
992
|
-
Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
|
|
872
|
+
description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
|
|
873
|
+
|
|
874
|
+
When to use:
|
|
875
|
+
- "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
|
|
876
|
+
|
|
877
|
+
Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
|
|
878
|
+
|
|
879
|
+
Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
|
|
880
|
+
|
|
881
|
+
Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
|
|
993
882
|
Upstream cost: 1.`,
|
|
994
883
|
inputSchema: {
|
|
995
884
|
type: 'object',
|
|
@@ -1080,21 +969,6 @@ Upstream cost: 1.`,
|
|
|
1080
969
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
1081
970
|
const summary = asRecord(body.career_summary) ?? {};
|
|
1082
971
|
const filters = asRecord(body.filters) ?? {};
|
|
1083
|
-
/**
|
|
1084
|
-
* Map an arbitrary {label: value} breakdown without assuming the upstream
|
|
1085
|
-
* key set: the endpoint has grown keys before and a hardcoded list would
|
|
1086
|
-
* silently drop new ones.
|
|
1087
|
-
*
|
|
1088
|
-
* Values that are OBJECTS are skipped rather than coerced to null. That
|
|
1089
|
-
* coercion is exactly what broke `bySurface`: the endpoint publishes
|
|
1090
|
-
* `surface_breakdown: { hard: { matches, won, lost, win_pct, titles }, ... }`
|
|
1091
|
-
* (one object per surface) and this ran each value through Number(), so
|
|
1092
|
-
* every tennis player's surface split came back
|
|
1093
|
-
* `{hard:null, clay:null, grass:null, carpet:null}` — permanently, on every
|
|
1094
|
-
* profile, while `player_profile` served the identical numbers from the
|
|
1095
|
-
* identical source. A key that is present and null reads as "this player
|
|
1096
|
-
* has no clay record", which is a worse lie than omitting it.
|
|
1097
|
-
*/
|
|
1098
972
|
const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
1099
973
|
const flatNumeric = (value) => {
|
|
1100
974
|
const rec = asRecord(value);
|
|
@@ -1108,12 +982,6 @@ Upstream cost: 1.`,
|
|
|
1108
982
|
}
|
|
1109
983
|
return Object.keys(out).length > 0 ? out : null;
|
|
1110
984
|
};
|
|
1111
|
-
/**
|
|
1112
|
-
* One OBJECT per bucket (surface, level, …), with the bucket's own fields
|
|
1113
|
-
* renamed to stable camelCase keys. A bucket that arrives as a bare number
|
|
1114
|
-
* is preserved as { value }, so a shape change narrows the payload instead
|
|
1115
|
-
* of emptying it.
|
|
1116
|
-
*/
|
|
1117
985
|
const bucketBlock = (value, fields) => {
|
|
1118
986
|
const rec = asRecord(value);
|
|
1119
987
|
if (!rec)
|
|
@@ -1148,8 +1016,6 @@ Upstream cost: 1.`,
|
|
|
1148
1016
|
const returnStats = flatNumeric(body.return_stats);
|
|
1149
1017
|
const clutchAndSituational = flatNumeric(body.clutch_and_situational);
|
|
1150
1018
|
const grandSlamBreakdown = bucketBlock(body.grand_slam_breakdown, SURFACE_FIELDS);
|
|
1151
|
-
// Sections this endpoint declares but does not publish for this player.
|
|
1152
|
-
// Named explicitly so an absent key is not read as "no such data exists".
|
|
1153
1019
|
const unavailable = {};
|
|
1154
1020
|
if (!byLevel) {
|
|
1155
1021
|
unavailable.byLevel =
|
package/dist/tools/rankings.js
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rankings_movers (TENNIS-07).
|
|
3
|
-
*
|
|
4
|
-
* Tennis-only: biggest ranking movers between the latest ranking list and its
|
|
5
|
-
* predecessor (GET /tennis/rankings/movers). The upstream payload already
|
|
6
|
-
* carries new_entries[] and dropped[] alongside items[], so one tool covers
|
|
7
|
-
* both halves of the deferred TENNIS-07 ask — there is no standalone
|
|
8
|
-
* /rankings/new_entries route (it 404s; verified 2026-09-08).
|
|
9
|
-
*/
|
|
10
1
|
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
11
2
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
12
3
|
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
@@ -197,17 +188,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
197
188
|
const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
|
|
198
189
|
const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
|
|
199
190
|
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
200
|
-
/**
|
|
201
|
-
* How far apart the two lists actually are.
|
|
202
|
-
*
|
|
203
|
-
* `previous_date` is simply the next list the archive holds — it is NOT
|
|
204
|
-
* guaranteed to be last week. On 2026-09-12 it was 2026-06-08 against a
|
|
205
|
-
* current 2026-08-31: an 84-day gap, because the weekly archive ends
|
|
206
|
-
* 2026-06-08 and the only snapshots after it are the current ESPN-sourced
|
|
207
|
-
* pair. Calling that "week-over-week movers" told every caller that a
|
|
208
|
-
* three-month climb was a one-week climb. The interval is measured and
|
|
209
|
-
* reported instead of assumed.
|
|
210
|
-
*/
|
|
211
191
|
const rankingDate = pickString(body.ranking_date) ?? null;
|
|
212
192
|
const previousDate = pickString(body.previous_date) ?? null;
|
|
213
193
|
const gapDays = (() => {
|
|
@@ -253,9 +233,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
253
233
|
within,
|
|
254
234
|
rankingDate,
|
|
255
235
|
previousDate,
|
|
256
|
-
/** Real interval between the two lists; null when only one list exists. */
|
|
257
236
|
gapDays,
|
|
258
|
-
/** 'week-over-week' only when the gap really is about a week. */
|
|
259
237
|
comparisonWindow,
|
|
260
238
|
comparisonsAreWeekly: gapDays !== null && gapDays <= 8,
|
|
261
239
|
items,
|
|
@@ -376,22 +354,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
376
354
|
const inWindow = sinceRaw
|
|
377
355
|
? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
|
|
378
356
|
: allHistory;
|
|
379
|
-
/**
|
|
380
|
-
* The route IGNORES ?limit= — verified: `?limit=4` returned all 342 rows of
|
|
381
|
-
* Alcaraz's career with total:342 and hasMore:false. Asking for 4 and
|
|
382
|
-
* receiving 342 is not a bound, it is a payload the caller cannot control,
|
|
383
|
-
* so the bound is applied here.
|
|
384
|
-
*
|
|
385
|
-
* Rows arrive oldest-first because that is the order a chart wants. limit
|
|
386
|
-
* therefore keeps the most RECENT N (the tail), matching every other tool
|
|
387
|
-
* in this server: `player_matches` returns the newest rows and reports
|
|
388
|
-
* hasMore when older ones exist. The older end is what gets dropped, and
|
|
389
|
-
* oldestReturned/newestReturned say exactly which window came back.
|
|
390
|
-
*/
|
|
391
357
|
const total = inWindow.length;
|
|
392
358
|
const history = total > limit ? inWindow.slice(total - limit) : inWindow;
|
|
393
|
-
// Best rank actually present in the returned window, so a caller can label a
|
|
394
|
-
// chart without recomputing. Null when the window is empty.
|
|
395
359
|
const best = history.reduce((acc, h) => {
|
|
396
360
|
if (h.rank === null)
|
|
397
361
|
return acc;
|
|
@@ -425,8 +389,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
425
389
|
since: sinceRaw || null,
|
|
426
390
|
bestRankInWindow: best,
|
|
427
391
|
history,
|
|
428
|
-
// The bound is applied locally because the route ignores ?limit=; state
|
|
429
|
-
// it so a caller does not read a short series as a short career.
|
|
430
392
|
totalInWindow: total,
|
|
431
393
|
returned: history.length,
|
|
432
394
|
omittedOlder: total - history.length,
|