cito-mcp 0.3.21 → 0.4.1

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.
@@ -53,9 +53,16 @@ function identityFrom(game, raw, idHint, slugHint) {
53
53
  * own domain, so it works from a browser without hotlink/CORS trouble.
54
54
  */
55
55
  images: {
56
- headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
56
+ // Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
57
+ // portrait), and no separate square headshot crop. Before this, the shaper
58
+ // read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of which the
59
+ // tennis route emits — so every tennis profile shipped four explicit nulls
60
+ // while a perfectly good photo sat one key away, and builders concluded
61
+ // tennis had no images at all. The portrait fills the headshot slot too:
62
+ // a card with the real photo slightly cropped beats a faceless card.
63
+ headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
57
64
  bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
58
- imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
65
+ imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
59
66
  proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
60
67
  },
61
68
  };
@@ -769,4 +776,417 @@ Parallel-safe: yes. Upstream cost: 1.`,
769
776
  });
770
777
  },
771
778
  };
772
- export const playerTools = [playerProfile, playerForm];
779
+ export const playerMatches = {
780
+ name: 'player_matches',
781
+ description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
782
+
783
+ When to use:
784
+ - "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
785
+
786
+ 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.
787
+
788
+ Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
789
+
790
+ Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
791
+ surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
792
+
793
+ Parallel-safe: yes. Upstream cost: 1.`,
794
+ inputSchema: {
795
+ type: 'object',
796
+ additionalProperties: false,
797
+ required: ['game', 'playerId'],
798
+ properties: {
799
+ game: gameSchema({ allowAll: false, required: true }),
800
+ playerId: stringSchema('Tennis player id, e.g. "atp_210097".', 'atp_210097'),
801
+ surface: stringSchema('Filter to one surface: Hard, Clay, or Grass.', 'Hard'),
802
+ tournamentId: stringSchema('Only matches at this tournament edition, e.g. "atp_2026_560".', 'atp_2026_560'),
803
+ limit: limitSchema({ default: 20, max: 50, description: 'Match rows (default 20, max 50).' }),
804
+ },
805
+ },
806
+ handler: async (args, ctx) => {
807
+ const started = Date.now();
808
+ const requestId = newRequestId();
809
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
810
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
811
+ return errorEnvelope({
812
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
813
+ message: gameParse.error ?? 'game is required',
814
+ game: null,
815
+ source: 'player_matches',
816
+ requestId,
817
+ tookMs: Date.now() - started,
818
+ });
819
+ }
820
+ const game = gameParse.game;
821
+ if (game !== 'tennis') {
822
+ return errorEnvelope({
823
+ code: 'NOT_IMPLEMENTED',
824
+ message: `player_matches is tennis-only (game '${game}' has its own match-log shape)`,
825
+ game,
826
+ source: 'player_matches',
827
+ requestId,
828
+ tookMs: Date.now() - started,
829
+ recover: ['Use match_summary or call_api for this game', 'Retry with game tennis'],
830
+ });
831
+ }
832
+ const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
833
+ if (!playerId) {
834
+ return errorEnvelope({
835
+ code: 'VALIDATION',
836
+ message: 'playerId is required',
837
+ game,
838
+ source: 'player_matches',
839
+ requestId,
840
+ tookMs: Date.now() - started,
841
+ recover: ['Find an id with search_entities or resolve_entity'],
842
+ });
843
+ }
844
+ const limit = clampInt(args.limit, 20, 1, 50);
845
+ const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
846
+ const tournamentId = typeof args.tournamentId === 'string' && args.tournamentId.trim()
847
+ ? args.tournamentId.trim()
848
+ : '';
849
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/matches`, {
850
+ query: {
851
+ limit,
852
+ ...(surfaceRaw ? { surface: surfaceRaw } : {}),
853
+ ...(tournamentId ? { tournament_id: tournamentId } : {}),
854
+ },
855
+ });
856
+ if (!res.ok) {
857
+ return errorEnvelope({
858
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
859
+ message: `Player match log failed for '${playerId}' (HTTP ${res.status})`,
860
+ game,
861
+ source: 'player_matches',
862
+ requestId,
863
+ tookMs: Date.now() - started,
864
+ upstreamCalls: 1,
865
+ httpStatus: res.status,
866
+ rateLimit: res.headers,
867
+ recover: ['Check the player id with search_entities'],
868
+ });
869
+ }
870
+ const root = asRecord(res.data);
871
+ const body = asRecord(root?.data) ?? root ?? {};
872
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
873
+ const allRows = Array.isArray(body.items) ? body.items : [];
874
+ /**
875
+ * Derive the player's display name from their own side of any match.
876
+ *
877
+ * The match-log payload carries no player_name at all (verified against the
878
+ * live route: top-level keys are just items/total/page/page_size/has_next/
879
+ * has_prev/success/total_pages), so the earlier version titled every log
880
+ * "<id> match log" and reported playerName: null. Each row does carry the
881
+ * winner and loser objects, so whichever side matches the requested id gives
882
+ * a real name with no extra upstream call.
883
+ */
884
+ let derivedName = null;
885
+ for (const row of allRows) {
886
+ const r = asRecord(row) ?? {};
887
+ const w = asRecord(r.winner) ?? {};
888
+ const l = asRecord(r.loser) ?? {};
889
+ if (pickString(r.winner_id, w.id) === playerId) {
890
+ derivedName = pickString(w.name) ?? null;
891
+ }
892
+ else if (pickString(r.loser_id, l.id) === playerId) {
893
+ derivedName = pickString(l.name) ?? null;
894
+ }
895
+ if (derivedName)
896
+ break;
897
+ }
898
+ /**
899
+ * The route ignores ?limit= (verified: limit=5 returned 20), so the bound is
900
+ * applied here. total comes from the upstream header so callers still learn
901
+ * how many matches exist beyond this page.
902
+ */
903
+ const total = typeof body.total === 'number' ? body.total : allRows.length;
904
+ const rows = allRows.slice(0, limit).map((row) => {
905
+ const r = asRecord(row) ?? {};
906
+ const winner = asRecord(r.winner) ?? {};
907
+ const loser = asRecord(r.loser) ?? {};
908
+ const winnerId = pickString(r.winner_id, winner.id);
909
+ const lost = winnerId !== null && winnerId !== playerId;
910
+ // Opponent is whichever side is not this player. When the row's winner
911
+ // does not match the requested id the player lost, so the opponent is the
912
+ // winner.
913
+ const opponent = lost ? winner : loser;
914
+ return {
915
+ matchId: pickString(r.id, r.match_id) ?? null,
916
+ date: pickString(r.match_date, r.date) ?? null,
917
+ tournamentId: pickString(r.tournament_id) ?? null,
918
+ tournamentName: pickString(r.tournament_name, r.name) ?? null,
919
+ level: pickString(r.level) ?? null,
920
+ category: pickString(r.category) ?? null,
921
+ surface: pickString(r.surface) ?? null,
922
+ round: pickString(r.round) ?? null,
923
+ roundName: pickString(r.round_name) ?? null,
924
+ bestOf: num(r.best_of),
925
+ score: pickString(r.score, r.score_raw) ?? null,
926
+ outcome: pickString(r.outcome, r.status) ?? null,
927
+ result: lost ? 'L' : 'W',
928
+ opponent: {
929
+ id: pickString(opponent.id) ?? null,
930
+ name: pickString(opponent.name) ?? null,
931
+ country: pickString(opponent.ioc) ?? null,
932
+ rank: num(opponent.rank),
933
+ seed: opponent.seed ?? null,
934
+ },
935
+ };
936
+ });
937
+ return successEnvelope({
938
+ pagination: {
939
+ limit,
940
+ offset: 0,
941
+ total,
942
+ hasMore: total > rows.length,
943
+ nextCursor: null,
944
+ prevCursor: null,
945
+ },
946
+ source: 'player_matches',
947
+ game,
948
+ requestId,
949
+ tookMs: Date.now() - started,
950
+ upstreamCalls: 1,
951
+ rateLimit: res.headers,
952
+ entities: { games: [game], ids: { playerId } },
953
+ data: {
954
+ title: `${derivedName ?? playerId} match log`,
955
+ playerId,
956
+ playerName: derivedName,
957
+ surfaceFilter: surfaceRaw || null,
958
+ tournamentFilter: tournamentId || null,
959
+ matches: rows,
960
+ total,
961
+ // Named explicitly because the bound is applied locally: the upstream
962
+ // route returns everything and ignores ?limit=.
963
+ returned: rows.length,
964
+ },
965
+ });
966
+ },
967
+ };
968
+ export const playerStats = {
969
+ name: 'player_stats',
970
+ 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.
971
+
972
+ When to use:
973
+ - "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
974
+
975
+ Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
976
+
977
+ Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
978
+
979
+ Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
980
+ Upstream cost: 1.`,
981
+ inputSchema: {
982
+ type: 'object',
983
+ additionalProperties: false,
984
+ required: ['game', 'playerId'],
985
+ properties: {
986
+ game: gameSchema({ allowAll: false, required: true }),
987
+ playerId: stringSchema('Tennis player id, e.g. "atp_207989".', 'atp_207989'),
988
+ surface: stringSchema('Filter to one surface: Hard, Clay, Grass, or Carpet.', 'Clay'),
989
+ yearFrom: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive start year, e.g. 2020.' },
990
+ yearTo: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive end year, e.g. 2026.' },
991
+ },
992
+ },
993
+ handler: async (args, ctx) => {
994
+ const started = Date.now();
995
+ const requestId = newRequestId();
996
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
997
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
998
+ return errorEnvelope({
999
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
1000
+ message: gameParse.error ?? 'game is required',
1001
+ game: null,
1002
+ source: 'player_stats',
1003
+ requestId,
1004
+ tookMs: Date.now() - started,
1005
+ });
1006
+ }
1007
+ const game = gameParse.game;
1008
+ if (game !== 'tennis') {
1009
+ return errorEnvelope({
1010
+ code: 'NOT_IMPLEMENTED',
1011
+ message: `player_stats is tennis-only (game '${game}' exposes stats elsewhere)`,
1012
+ game,
1013
+ source: 'player_stats',
1014
+ requestId,
1015
+ tookMs: Date.now() - started,
1016
+ recover: ['Use player_profile for this game', 'Retry with game tennis'],
1017
+ });
1018
+ }
1019
+ const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
1020
+ if (!playerId) {
1021
+ return errorEnvelope({
1022
+ code: 'VALIDATION',
1023
+ message: 'playerId is required',
1024
+ game,
1025
+ source: 'player_stats',
1026
+ requestId,
1027
+ tookMs: Date.now() - started,
1028
+ recover: ['Find an id with search_entities or resolve_entity'],
1029
+ });
1030
+ }
1031
+ const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
1032
+ const yf = typeof args.yearFrom === 'number' && Number.isFinite(args.yearFrom) ? Math.trunc(args.yearFrom) : null;
1033
+ const yt = typeof args.yearTo === 'number' && Number.isFinite(args.yearTo) ? Math.trunc(args.yearTo) : null;
1034
+ if (yf !== null && yt !== null && yf > yt) {
1035
+ return errorEnvelope({
1036
+ code: 'VALIDATION',
1037
+ message: `yearFrom (${yf}) must not be greater than yearTo (${yt})`,
1038
+ game,
1039
+ source: 'player_stats',
1040
+ requestId,
1041
+ tookMs: Date.now() - started,
1042
+ });
1043
+ }
1044
+ const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/stats`, {
1045
+ query: {
1046
+ ...(surfaceRaw ? { surface: surfaceRaw } : {}),
1047
+ ...(yf !== null ? { year_from: yf } : {}),
1048
+ ...(yt !== null ? { year_to: yt } : {}),
1049
+ },
1050
+ });
1051
+ if (!res.ok) {
1052
+ return errorEnvelope({
1053
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
1054
+ message: `Player stats failed for '${playerId}' (HTTP ${res.status})`,
1055
+ game,
1056
+ source: 'player_stats',
1057
+ requestId,
1058
+ tookMs: Date.now() - started,
1059
+ upstreamCalls: 1,
1060
+ httpStatus: res.status,
1061
+ rateLimit: res.headers,
1062
+ recover: ['Check the player id with search_entities'],
1063
+ });
1064
+ }
1065
+ const root = asRecord(res.data);
1066
+ const body = asRecord(root?.data) ?? root ?? {};
1067
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
1068
+ const summary = asRecord(body.career_summary) ?? {};
1069
+ const filters = asRecord(body.filters) ?? {};
1070
+ /**
1071
+ * Map an arbitrary {label: value} breakdown without assuming the upstream
1072
+ * key set: the endpoint has grown keys before and a hardcoded list would
1073
+ * silently drop new ones.
1074
+ *
1075
+ * Values that are OBJECTS are skipped rather than coerced to null. That
1076
+ * coercion is exactly what broke `bySurface`: the endpoint publishes
1077
+ * `surface_breakdown: { hard: { matches, won, lost, win_pct, titles }, ... }`
1078
+ * (one object per surface) and this ran each value through Number(), so
1079
+ * every tennis player's surface split came back
1080
+ * `{hard:null, clay:null, grass:null, carpet:null}` — permanently, on every
1081
+ * profile, while `player_profile` served the identical numbers from the
1082
+ * identical source. A key that is present and null reads as "this player
1083
+ * has no clay record", which is a worse lie than omitting it.
1084
+ */
1085
+ const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
1086
+ const flatNumeric = (value) => {
1087
+ const rec = asRecord(value);
1088
+ if (!rec)
1089
+ return null;
1090
+ const out = {};
1091
+ for (const [k, v] of Object.entries(rec)) {
1092
+ if (asRecord(v))
1093
+ continue;
1094
+ out[camel(k)] = num(v);
1095
+ }
1096
+ return Object.keys(out).length > 0 ? out : null;
1097
+ };
1098
+ /**
1099
+ * One OBJECT per bucket (surface, level, …), with the bucket's own fields
1100
+ * renamed to stable camelCase keys. A bucket that arrives as a bare number
1101
+ * is preserved as { value }, so a shape change narrows the payload instead
1102
+ * of emptying it.
1103
+ */
1104
+ const bucketBlock = (value, fields) => {
1105
+ const rec = asRecord(value);
1106
+ if (!rec)
1107
+ return null;
1108
+ const out = {};
1109
+ for (const [bucket, raw] of Object.entries(rec)) {
1110
+ const inner = asRecord(raw);
1111
+ if (inner) {
1112
+ const mapped = {};
1113
+ for (const [outKey, srcKey] of fields) {
1114
+ if (srcKey in inner)
1115
+ mapped[outKey] = num(inner[srcKey]);
1116
+ }
1117
+ out[bucket] = Object.keys(mapped).length > 0 ? mapped : flatNumeric(inner) ?? {};
1118
+ }
1119
+ else {
1120
+ out[bucket] = { value: num(raw) };
1121
+ }
1122
+ }
1123
+ return Object.keys(out).length > 0 ? out : null;
1124
+ };
1125
+ const SURFACE_FIELDS = [
1126
+ ['matches', 'matches'],
1127
+ ['won', 'won'],
1128
+ ['lost', 'lost'],
1129
+ ['winPct', 'win_pct'],
1130
+ ['titles', 'titles'],
1131
+ ];
1132
+ const bySurface = bucketBlock(body.surface_breakdown ?? body.by_surface, SURFACE_FIELDS);
1133
+ const byLevel = bucketBlock(body.by_level ?? body.level_breakdown, SURFACE_FIELDS);
1134
+ const servingStats = flatNumeric(body.serving_stats);
1135
+ const returnStats = flatNumeric(body.return_stats);
1136
+ const clutchAndSituational = flatNumeric(body.clutch_and_situational);
1137
+ const grandSlamBreakdown = bucketBlock(body.grand_slam_breakdown, SURFACE_FIELDS);
1138
+ // Sections this endpoint declares but does not publish for this player.
1139
+ // Named explicitly so an absent key is not read as "no such data exists".
1140
+ const unavailable = {};
1141
+ if (!byLevel) {
1142
+ unavailable.byLevel =
1143
+ 'This endpoint publishes no per-level breakdown. Pass level= to a player_stats call, or use tournaments/event_card for level-scoped results.';
1144
+ }
1145
+ if (!returnStats)
1146
+ unavailable.returnStats = 'Not published for this player yet.';
1147
+ if (!clutchAndSituational) {
1148
+ unavailable.clutchAndSituational = 'Not published for this player yet.';
1149
+ }
1150
+ if (!grandSlamBreakdown) {
1151
+ unavailable.grandSlamBreakdown = 'Not published for this player yet.';
1152
+ }
1153
+ return successEnvelope({
1154
+ source: 'player_stats',
1155
+ game,
1156
+ requestId,
1157
+ tookMs: Date.now() - started,
1158
+ upstreamCalls: 1,
1159
+ rateLimit: res.headers,
1160
+ entities: { games: [game], ids: { playerId } },
1161
+ data: {
1162
+ title: `${pickString(body.player_name) ?? playerId} career statistics`,
1163
+ playerId: pickString(body.player_id) ?? playerId,
1164
+ playerName: pickString(body.player_name) ?? null,
1165
+ filters: {
1166
+ surface: pickString(filters.surface) ?? null,
1167
+ level: pickString(filters.level) ?? null,
1168
+ yearFrom: num(filters.year_from),
1169
+ yearTo: num(filters.year_to),
1170
+ },
1171
+ careerSummary: {
1172
+ matchesPlayed: num(summary.matches_played),
1173
+ matchesWon: num(summary.matches_won),
1174
+ matchesLost: num(summary.matches_lost),
1175
+ winPercentage: num(summary.win_percentage),
1176
+ titles: num(summary.titles_count),
1177
+ grandSlamTitles: num(summary.grand_slam_titles),
1178
+ mastersTitles: num(summary.masters_titles),
1179
+ finalsReached: num(summary.finals_reached),
1180
+ },
1181
+ bySurface,
1182
+ byLevel,
1183
+ servingStats,
1184
+ returnStats,
1185
+ clutchAndSituational,
1186
+ grandSlamBreakdown,
1187
+ ...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
1188
+ },
1189
+ });
1190
+ },
1191
+ };
1192
+ export const playerTools = [playerProfile, playerForm, playerMatches, playerStats];
@@ -82,6 +82,8 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
82
82
 
83
83
  Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
84
84
 
85
+ The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
86
+
85
87
  Parallel-safe: yes. Upstream cost: 1.`,
86
88
  inputSchema: {
87
89
  type: 'object',
@@ -195,6 +197,39 @@ Parallel-safe: yes. Upstream cost: 1.`,
195
197
  const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
196
198
  const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
197
199
  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
+ const rankingDate = pickString(body.ranking_date) ?? null;
212
+ const previousDate = pickString(body.previous_date) ?? null;
213
+ const gapDays = (() => {
214
+ if (!rankingDate || !previousDate)
215
+ return null;
216
+ const a = Date.parse(rankingDate);
217
+ const b = Date.parse(previousDate);
218
+ if (!Number.isFinite(a) || !Number.isFinite(b))
219
+ return null;
220
+ return Math.round((a - b) / 86_400_000);
221
+ })();
222
+ const comparisonWindow = gapDays === null
223
+ ? null
224
+ : gapDays <= 8
225
+ ? 'week-over-week'
226
+ : `${Math.round(gapDays / 7)}-week gap`;
227
+ const warnings = [];
228
+ if (gapDays !== null && gapDays > 10) {
229
+ warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
230
+ `Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
231
+ `ranking snapshot strictly between those two dates. Treat the deltas as period-over-period, not weekly.`);
232
+ }
198
233
  return successEnvelope({
199
234
  pagination: {
200
235
  limit,
@@ -210,13 +245,19 @@ Parallel-safe: yes. Upstream cost: 1.`,
210
245
  tookMs: Date.now() - started,
211
246
  upstreamCalls: 1,
212
247
  rateLimit: res.headers,
248
+ warnings: warnings.length ? warnings : undefined,
213
249
  data: {
214
250
  title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
215
251
  tour: tourParse.tour,
216
252
  direction: dirParse.direction,
217
253
  within,
218
- rankingDate: pickString(body.ranking_date) ?? null,
219
- previousDate: pickString(body.previous_date) ?? null,
254
+ rankingDate,
255
+ previousDate,
256
+ /** Real interval between the two lists; null when only one list exists. */
257
+ gapDays,
258
+ /** 'week-over-week' only when the gap really is about a week. */
259
+ comparisonWindow,
260
+ comparisonsAreWeekly: gapDays !== null && gapDays <= 8,
220
261
  items,
221
262
  newEntries,
222
263
  dropped,
@@ -225,4 +266,174 @@ Parallel-safe: yes. Upstream cost: 1.`,
225
266
  });
226
267
  },
227
268
  };
228
- export const rankingsTools = [rankingsMovers];
269
+ export const playerRankingsHistory = {
270
+ name: 'player_rankings_history',
271
+ description: `Tennis player ranking trajectory: every published ranking date for one player, plus career-high and weeks at No.1.
272
+
273
+ When to use:
274
+ - "Show Alcaraz's ranking over time"; career-high rank and when it was reached; how a player climbed; plotting a rank chart.
275
+
276
+ Prefer over: standings (latest snapshot only); rankings_movers (week-over-week delta for a whole tour); player_profile (has career-high but no per-date series).
277
+
278
+ Do not use when: the current top-N table → standings with game tennis.
279
+
280
+ Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
281
+ wants; the newest entries are the tail. limit keeps the most RECENT N rows (the
282
+ route itself ignores ?limit=, so the bound is applied here) — hasMore and
283
+ oldestReturned/newestReturned say exactly which window came back, and
284
+ omittedOlder counts the rows left behind at the old end.
285
+
286
+ Parallel-safe: yes. Upstream cost: 1.`,
287
+ inputSchema: {
288
+ type: 'object',
289
+ additionalProperties: false,
290
+ required: ['game', 'playerId'],
291
+ properties: {
292
+ game: gameSchema({ allowAll: false, required: true }),
293
+ playerId: stringSchema('Tennis player id, e.g. "atp_207989" or "wta_214544".', 'atp_207989'),
294
+ since: stringSchema('Only rows on or after this date (YYYY-MM-DD).', '2026-01-01'),
295
+ limit: limitSchema({ default: 20, max: 500, description: 'Max history rows (default 20, max 500).' }),
296
+ },
297
+ },
298
+ handler: async (args, ctx) => {
299
+ const started = Date.now();
300
+ const requestId = newRequestId();
301
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
302
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
303
+ return errorEnvelope({
304
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
305
+ message: gameParse.error ?? 'game is required',
306
+ game: null,
307
+ source: 'player_rankings_history',
308
+ requestId,
309
+ tookMs: Date.now() - started,
310
+ });
311
+ }
312
+ const game = gameParse.game;
313
+ if (game !== 'tennis') {
314
+ return errorEnvelope({
315
+ code: 'NOT_IMPLEMENTED',
316
+ message: `player_rankings_history is tennis-only (game '${game}' has no ranking-history endpoint)`,
317
+ game,
318
+ source: 'player_rankings_history',
319
+ requestId,
320
+ tookMs: Date.now() - started,
321
+ recover: ['Use standings for the latest snapshot of this game', 'Retry with game tennis'],
322
+ });
323
+ }
324
+ const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
325
+ if (!playerId) {
326
+ return errorEnvelope({
327
+ code: 'VALIDATION',
328
+ message: 'playerId is required',
329
+ game,
330
+ source: 'player_rankings_history',
331
+ requestId,
332
+ tookMs: Date.now() - started,
333
+ recover: ['Find an id with search_entities or resolve_entity'],
334
+ });
335
+ }
336
+ const sinceRaw = typeof args.since === 'string' ? args.since.trim() : '';
337
+ if (sinceRaw && !/^\d{4}-\d{2}-\d{2}$/.test(sinceRaw)) {
338
+ return errorEnvelope({
339
+ code: 'VALIDATION',
340
+ message: `since must be YYYY-MM-DD (got '${args.since}')`,
341
+ game,
342
+ source: 'player_rankings_history',
343
+ requestId,
344
+ tookMs: Date.now() - started,
345
+ });
346
+ }
347
+ const limit = clampInt(args.limit, 20, 1, 500);
348
+ const res = await fetchJson(ctx, `/tennis/rankings/history/${encodeURIComponent(playerId)}`, {
349
+ query: { limit },
350
+ });
351
+ if (!res.ok) {
352
+ return errorEnvelope({
353
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
354
+ message: `Ranking history failed for '${playerId}' (HTTP ${res.status})`,
355
+ game,
356
+ source: 'player_rankings_history',
357
+ requestId,
358
+ tookMs: Date.now() - started,
359
+ upstreamCalls: 1,
360
+ httpStatus: res.status,
361
+ rateLimit: res.headers,
362
+ recover: ['Check the player id with search_entities'],
363
+ });
364
+ }
365
+ const root = asRecord(res.data) ?? {};
366
+ const body = asRecord(root.data) ?? root;
367
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
368
+ const allHistory = (Array.isArray(body.history) ? body.history : []).map((row) => {
369
+ const r = asRecord(row) ?? {};
370
+ return {
371
+ date: pickString(r.date) ?? null,
372
+ rank: num(r.rank),
373
+ points: num(r.points),
374
+ };
375
+ });
376
+ const inWindow = sinceRaw
377
+ ? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
378
+ : 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
+ const total = inWindow.length;
392
+ 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
+ const best = history.reduce((acc, h) => {
396
+ if (h.rank === null)
397
+ return acc;
398
+ if (!acc || acc.rank === null || h.rank < acc.rank)
399
+ return { date: h.date, rank: h.rank };
400
+ return acc;
401
+ }, null);
402
+ return successEnvelope({
403
+ pagination: {
404
+ limit,
405
+ offset: total - history.length,
406
+ total,
407
+ hasMore: total > history.length,
408
+ nextCursor: null,
409
+ prevCursor: null,
410
+ },
411
+ source: 'player_rankings_history',
412
+ game,
413
+ requestId,
414
+ tookMs: Date.now() - started,
415
+ upstreamCalls: 1,
416
+ rateLimit: res.headers,
417
+ data: {
418
+ title: `${pickString(body.player_name) ?? playerId} ranking history`,
419
+ playerId: pickString(body.player_id) ?? playerId,
420
+ playerName: pickString(body.player_name) ?? null,
421
+ tour: pickString(body.tour) ?? null,
422
+ careerHighRank: num(body.career_high_rank),
423
+ careerHighDate: pickString(body.career_high_date) ?? null,
424
+ weeksAtNo1: num(body.total_weeks_at_no_1),
425
+ since: sinceRaw || null,
426
+ bestRankInWindow: best,
427
+ 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
+ totalInWindow: total,
431
+ returned: history.length,
432
+ omittedOlder: total - history.length,
433
+ oldestReturned: history.length ? history[0].date : null,
434
+ newestReturned: history.length ? history[history.length - 1].date : null,
435
+ },
436
+ });
437
+ },
438
+ };
439
+ export const rankingsTools = [rankingsMovers, playerRankingsHistory];