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.
@@ -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
  });
@@ -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`; // list/search; detail via radar
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 =
@@ -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,