cito-mcp 0.3.19 → 0.3.21

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 CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
4
4
 
5
- **Version:** `0.2.4` · **Node:** `>=20` · **Install:** `npx cito-mcp`
5
+ **Version:** `0.3.20` · **Node:** `>=20` · **Install:** `npx cito-mcp`
6
6
 
7
- Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail REST stay available via `call_api`.
7
+ Primary games & sports: **lol · cs2 · dota2 · cod · ufc · tennis**. Long-tail REST stays available via `call_api`.
8
8
 
9
9
  ---
10
10
 
@@ -12,7 +12,7 @@ Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail RES
12
12
 
13
13
  v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among thin path wrappers, invent IDs, and stitch multi-call UI screens themselves. That catalog was hard to select against and brittle across games.
14
14
 
15
- **v0.2 ships 16 hand-authored tools** that answer *jobs* instead of mirroring REST:
15
+ **v0.3 ships 18 hand-authored tools** that answer *jobs* instead of mirroring REST:
16
16
 
17
17
  | Job | Tool |
18
18
  | --- | --- |
@@ -21,9 +21,11 @@ v0.1 exposed ~100+ tools auto-generated from OpenAPI. Agents had to pick among t
21
21
  | Build a match card / recap | `match_summary` |
22
22
  | Deep timeline / live state | `match_details` |
23
23
  | Team page | `team_profile` |
24
- | Player / fighter form | `player_profile` |
24
+ | Player / fighter profile | `player_profile` |
25
+ | Player form & win streak | `player_form` |
25
26
  | Table / rankings | `standings` |
26
- | Pre-match briefing | `match_preview` |
27
+ | Ranking climbers & droppers | `rankings_movers` |
28
+ | Pre-match briefing (with surface) | `match_preview` |
27
29
  | Event / fight-night card | `event_card` |
28
30
  | Rivalry record | `head_to_head` |
29
31
  | Fighter / team photos | `event_card` · `player_profile` |
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Dedicated CS2 esports analytics & live scorebot MCP tools.
3
+ *
4
+ * Exposes deep CS2 domain data from the 161 backend endpoints:
5
+ * - Live real-time scoreboards & round state
6
+ * - Round-by-round tactical economies & buy classifications
7
+ * - First blood / opening duels per player & map
8
+ * - 1vX clutch success rates (1v1 through 1v5)
9
+ * - Team map pool analytics (Mirage, Inferno, Nuke, Dust2, Ancient, Anubis, Vertigo)
10
+ * - Sub-second utility & flashbang efficiency leaderboard
11
+ * - Match map pick/ban veto sequence
12
+ * - Pro roster changes & transfers
13
+ */
14
+ import { asRecord, clampInt, fetchJson, unwrapPayload, } from '../client.js';
15
+ import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
16
+ import { limitSchema, READ_TOOL_ANNOTATIONS, stringSchema, } from './types.js';
17
+ /** Unwraps Cito envelope whether inner entity is an object or array */
18
+ function unwrap(val) {
19
+ const p = unwrapPayload(val);
20
+ const r = asRecord(p);
21
+ if (r && Array.isArray(r.data))
22
+ return r.data;
23
+ if (r && Array.isArray(r.items))
24
+ return r.items;
25
+ return p;
26
+ }
27
+ /**
28
+ * 1. cs2_live_scoreboard
29
+ * Real-time round state, bomb status, and player combat stats for an active match.
30
+ */
31
+ export const cs2LiveScoreboard = {
32
+ name: 'cs2_live_scoreboard',
33
+ description: 'Live real-time in-game scoreboard for an active CS2 match. Returns current round, bomb status, team scores, and individual player stats (K/A/D, ADR, HS%, alive status, HP, armor, weapon, and equipment value).',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ matchId: stringSchema('Match ID (e.g. "cs2-match-2397733" or "2397733"). Required.', 'cs2-match-2397733'),
38
+ },
39
+ required: ['matchId'],
40
+ additionalProperties: false,
41
+ },
42
+ annotations: READ_TOOL_ANNOTATIONS,
43
+ handler: async (args, ctx) => {
44
+ const reqId = newRequestId();
45
+ const rawId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
46
+ if (!rawId) {
47
+ return errorEnvelope({
48
+ code: 'VALIDATION',
49
+ message: 'matchId is required',
50
+ game: 'cs2',
51
+ source: 'cs2_live_scoreboard',
52
+ requestId: reqId,
53
+ });
54
+ }
55
+ const cleanId = rawId.replace(/^cs2-match-/, '');
56
+ const path = `/cs2/live/${encodeURIComponent(cleanId)}/scoreboard`;
57
+ const res = await fetchJson(ctx, path);
58
+ if (!res.ok) {
59
+ return errorEnvelope({
60
+ code: mapHttpToCode(res.status),
61
+ message: `Live scoreboard fetch failed: ${res.status} on ${path}`,
62
+ game: 'cs2',
63
+ source: 'cs2_live_scoreboard',
64
+ requestId: reqId,
65
+ httpStatus: res.status,
66
+ });
67
+ }
68
+ const payload = unwrap(res.data);
69
+ return successEnvelope({
70
+ data: payload,
71
+ game: 'cs2',
72
+ source: 'cs2_live_scoreboard',
73
+ requestId: reqId,
74
+ rateLimit: res.headers,
75
+ });
76
+ },
77
+ };
78
+ /**
79
+ * 2. cs2_round_economy
80
+ * Tactical round-by-round buy classification and equipment values for a map.
81
+ */
82
+ export const cs2RoundEconomy = {
83
+ name: 'cs2_round_economy',
84
+ description: 'Round-by-round tactical economy for a CS2 map. Classifies every round buy type (Full Buy, Force Buy, Semi-Eco, Full Eco), team equipment spend, freeze-time equipment values, and loss bonus counter.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ mapId: stringSchema('Map ID or Game ID (e.g. "7716-map-1" or "cs2-game-123"). Required.', '7716-map-1'),
89
+ },
90
+ required: ['mapId'],
91
+ additionalProperties: false,
92
+ },
93
+ annotations: READ_TOOL_ANNOTATIONS,
94
+ handler: async (args, ctx) => {
95
+ const reqId = newRequestId();
96
+ const rawMapId = typeof args.mapId === 'string' ? args.mapId.trim() : '';
97
+ if (!rawMapId) {
98
+ return errorEnvelope({
99
+ code: 'VALIDATION',
100
+ message: 'mapId is required',
101
+ game: 'cs2',
102
+ source: 'cs2_round_economy',
103
+ requestId: reqId,
104
+ });
105
+ }
106
+ const path = `/cs2/maps/${encodeURIComponent(rawMapId)}/rounds`;
107
+ let res = await fetchJson(ctx, path);
108
+ if (!res.ok && res.status === 404) {
109
+ res = await fetchJson(ctx, `/cs2/games/${encodeURIComponent(rawMapId)}/rounds`);
110
+ }
111
+ if (!res.ok) {
112
+ return errorEnvelope({
113
+ code: mapHttpToCode(res.status),
114
+ message: `Round economy fetch failed: ${res.status} on ${path}`,
115
+ game: 'cs2',
116
+ source: 'cs2_round_economy',
117
+ requestId: reqId,
118
+ httpStatus: res.status,
119
+ });
120
+ }
121
+ const payload = unwrap(res.data);
122
+ return successEnvelope({
123
+ data: payload,
124
+ game: 'cs2',
125
+ source: 'cs2_round_economy',
126
+ requestId: reqId,
127
+ rateLimit: res.headers,
128
+ });
129
+ },
130
+ };
131
+ /**
132
+ * 3. cs2_opening_duels
133
+ * First blood / opening kill statistics by player or overall leaderboard.
134
+ */
135
+ export const cs2OpeningDuels = {
136
+ name: 'cs2_opening_duels',
137
+ description: 'First blood and opening duel statistics. Pass playerId to view a pro player\'s First Kills (FK), First Deaths (FD), and opening duel conversion %, or omit playerId to view the global CS2 opening duel leaderboard.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ playerId: stringSchema('Optional player ID or slug (e.g. "chucky" or "cs2-player-123"). If omitted, returns global leaderboard.', 'chucky'),
142
+ limit: limitSchema({ default: 20, max: 100 }),
143
+ },
144
+ additionalProperties: false,
145
+ },
146
+ annotations: READ_TOOL_ANNOTATIONS,
147
+ handler: async (args, ctx) => {
148
+ const reqId = newRequestId();
149
+ const rawPlayer = typeof args.playerId === 'string' ? args.playerId.trim() : '';
150
+ const limit = clampInt(args.limit, 20, 1, 100);
151
+ const path = rawPlayer
152
+ ? `/cs2/players/${encodeURIComponent(rawPlayer)}/opening-duels`
153
+ : `/cs2/stats/opening-duels`;
154
+ const res = await fetchJson(ctx, path, { query: { limit } });
155
+ if (!res.ok) {
156
+ return errorEnvelope({
157
+ code: mapHttpToCode(res.status),
158
+ message: `Opening duels fetch failed: ${res.status} on ${path}`,
159
+ game: 'cs2',
160
+ source: 'cs2_opening_duels',
161
+ requestId: reqId,
162
+ httpStatus: res.status,
163
+ });
164
+ }
165
+ const payload = unwrap(res.data);
166
+ return successEnvelope({
167
+ data: payload,
168
+ game: 'cs2',
169
+ source: 'cs2_opening_duels',
170
+ requestId: reqId,
171
+ rateLimit: res.headers,
172
+ });
173
+ },
174
+ };
175
+ /**
176
+ * 4. cs2_clutches
177
+ * 1vX clutch situation success records (1v1 through 1v5).
178
+ */
179
+ export const cs2Clutches = {
180
+ name: 'cs2_clutches',
181
+ description: '1vX clutch situation success records. Pass playerId for a specific player\'s clutch breakdown (1v1, 1v2, 1v3, 1v4, 1v5 attempted vs won), or omit to view the global clutch leaderboard.',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ playerId: stringSchema('Optional player ID or slug (e.g. "xm1nd" or "cs2-player-456"). If omitted, returns global leaderboard.', 'xm1nd'),
186
+ limit: limitSchema({ default: 20, max: 100 }),
187
+ },
188
+ additionalProperties: false,
189
+ },
190
+ annotations: READ_TOOL_ANNOTATIONS,
191
+ handler: async (args, ctx) => {
192
+ const reqId = newRequestId();
193
+ const rawPlayer = typeof args.playerId === 'string' ? args.playerId.trim() : '';
194
+ const limit = clampInt(args.limit, 20, 1, 100);
195
+ const path = rawPlayer
196
+ ? `/cs2/players/${encodeURIComponent(rawPlayer)}/clutches`
197
+ : `/cs2/stats/clutches`;
198
+ const res = await fetchJson(ctx, path, { query: { limit } });
199
+ if (!res.ok) {
200
+ return errorEnvelope({
201
+ code: mapHttpToCode(res.status),
202
+ message: `Clutches fetch failed: ${res.status} on ${path}`,
203
+ game: 'cs2',
204
+ source: 'cs2_clutches',
205
+ requestId: reqId,
206
+ httpStatus: res.status,
207
+ });
208
+ }
209
+ const payload = unwrap(res.data);
210
+ return successEnvelope({
211
+ data: payload,
212
+ game: 'cs2',
213
+ source: 'cs2_clutches',
214
+ requestId: reqId,
215
+ rateLimit: res.headers,
216
+ });
217
+ },
218
+ };
219
+ /**
220
+ * 5. cs2_team_map_stats
221
+ * Team performance across the competitive map pool.
222
+ */
223
+ export const cs2TeamMapStats = {
224
+ name: 'cs2_team_map_stats',
225
+ description: 'Team win rates, round win rates, and CT/T side win splits across the competitive map pool (Mirage, Inferno, Nuke, Dust2, Ancient, Anubis, Vertigo) over the last 30 or 90 days.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ teamId: stringSchema('Team ID or slug (e.g. "hltv-team-4608", "cs2-team-4608", or "natus-vincere"). Required.', 'hltv-team-4608'),
230
+ days: {
231
+ type: 'integer',
232
+ description: 'Timeframe in days: 30, 90, 180, or 365 (default 90).',
233
+ default: 90,
234
+ },
235
+ },
236
+ required: ['teamId'],
237
+ additionalProperties: false,
238
+ },
239
+ annotations: READ_TOOL_ANNOTATIONS,
240
+ handler: async (args, ctx) => {
241
+ const reqId = newRequestId();
242
+ const rawTeam = typeof args.teamId === 'string' ? args.teamId.trim() : '';
243
+ if (!rawTeam) {
244
+ return errorEnvelope({
245
+ code: 'VALIDATION',
246
+ message: 'teamId is required',
247
+ game: 'cs2',
248
+ source: 'cs2_team_map_stats',
249
+ requestId: reqId,
250
+ });
251
+ }
252
+ const days = typeof args.days === 'number' ? args.days : 90;
253
+ const path = `/cs2/teams/${encodeURIComponent(rawTeam)}/map-stats`;
254
+ const res = await fetchJson(ctx, path, { query: { days } });
255
+ if (!res.ok) {
256
+ return errorEnvelope({
257
+ code: mapHttpToCode(res.status),
258
+ message: `Team map stats fetch failed: ${res.status} on ${path}`,
259
+ game: 'cs2',
260
+ source: 'cs2_team_map_stats',
261
+ requestId: reqId,
262
+ httpStatus: res.status,
263
+ });
264
+ }
265
+ const payload = unwrap(res.data);
266
+ return successEnvelope({
267
+ data: payload,
268
+ game: 'cs2',
269
+ source: 'cs2_team_map_stats',
270
+ requestId: reqId,
271
+ rateLimit: res.headers,
272
+ });
273
+ },
274
+ };
275
+ /**
276
+ * 6. cs2_utility_leaderboard
277
+ * Sub-second cached leaderboard for flashbang & utility efficiency.
278
+ */
279
+ export const cs2UtilityLeaderboard = {
280
+ name: 'cs2_utility_leaderboard',
281
+ description: 'Sub-second cached leaderboard of CS2 utility and flashbang efficiency. Returns pro player rankings for effective blind duration per flash, enemy blind time, flashes thrown, and grenade ADR.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ limit: limitSchema({ default: 20, max: 100 }),
286
+ },
287
+ additionalProperties: false,
288
+ },
289
+ annotations: READ_TOOL_ANNOTATIONS,
290
+ handler: async (args, ctx) => {
291
+ const reqId = newRequestId();
292
+ const limit = clampInt(args.limit, 20, 1, 100);
293
+ const path = `/cs2/stats/utility`;
294
+ const res = await fetchJson(ctx, path, { query: { limit } });
295
+ if (!res.ok) {
296
+ return errorEnvelope({
297
+ code: mapHttpToCode(res.status),
298
+ message: `Utility leaderboard fetch failed: ${res.status} on ${path}`,
299
+ game: 'cs2',
300
+ source: 'cs2_utility_leaderboard',
301
+ requestId: reqId,
302
+ httpStatus: res.status,
303
+ });
304
+ }
305
+ const payload = unwrap(res.data);
306
+ return successEnvelope({
307
+ data: payload,
308
+ game: 'cs2',
309
+ source: 'cs2_utility_leaderboard',
310
+ requestId: reqId,
311
+ rateLimit: res.headers,
312
+ });
313
+ },
314
+ };
315
+ /**
316
+ * 7. cs2_veto_sequence
317
+ * Map pick/ban sequence and veto history for a match.
318
+ */
319
+ export const cs2VetoSequence = {
320
+ name: 'cs2_veto_sequence',
321
+ description: 'Map pick/ban veto sequence for a CS2 match. Shows which team banned which map, map picks, and decider map in chronological order.',
322
+ inputSchema: {
323
+ type: 'object',
324
+ properties: {
325
+ matchId: stringSchema('Match ID (e.g. "2397603" or "cs2-match-2397603"). Required.', '2397603'),
326
+ },
327
+ required: ['matchId'],
328
+ additionalProperties: false,
329
+ },
330
+ annotations: READ_TOOL_ANNOTATIONS,
331
+ handler: async (args, ctx) => {
332
+ const reqId = newRequestId();
333
+ const rawId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
334
+ if (!rawId) {
335
+ return errorEnvelope({
336
+ code: 'VALIDATION',
337
+ message: 'matchId is required',
338
+ game: 'cs2',
339
+ source: 'cs2_veto_sequence',
340
+ requestId: reqId,
341
+ });
342
+ }
343
+ const cleanId = rawId.replace(/^cs2-match-/, '');
344
+ const path = `/cs2/matches/${encodeURIComponent(cleanId)}/head-to-head`;
345
+ const res = await fetchJson(ctx, path);
346
+ if (!res.ok) {
347
+ return errorEnvelope({
348
+ code: mapHttpToCode(res.status),
349
+ message: `Veto sequence fetch failed: ${res.status} on ${path}`,
350
+ game: 'cs2',
351
+ source: 'cs2_veto_sequence',
352
+ requestId: reqId,
353
+ httpStatus: res.status,
354
+ });
355
+ }
356
+ const payload = unwrap(res.data);
357
+ const r = asRecord(payload);
358
+ const veto = r?.veto ?? r?.vetoSequence ?? payload;
359
+ return successEnvelope({
360
+ data: veto,
361
+ game: 'cs2',
362
+ source: 'cs2_veto_sequence',
363
+ requestId: reqId,
364
+ rateLimit: res.headers,
365
+ });
366
+ },
367
+ };
368
+ /**
369
+ * 8. cs2_roster_transfers
370
+ * Recent professional roster changes, benchings, and transfers.
371
+ */
372
+ export const cs2RosterTransfers = {
373
+ name: 'cs2_roster_transfers',
374
+ description: 'Recent professional CS2 roster changes, player benchings, stand-ins, and team transfers.',
375
+ inputSchema: {
376
+ type: 'object',
377
+ properties: {
378
+ limit: limitSchema({ default: 20, max: 100 }),
379
+ },
380
+ additionalProperties: false,
381
+ },
382
+ annotations: READ_TOOL_ANNOTATIONS,
383
+ handler: async (args, ctx) => {
384
+ const reqId = newRequestId();
385
+ const limit = clampInt(args.limit, 20, 1, 100);
386
+ const path = `/cs2/transfers/recent`;
387
+ let res = await fetchJson(ctx, path, { query: { limit } });
388
+ if (!res.ok && res.status === 404) {
389
+ res = await fetchJson(ctx, `/cs2/roster-changes/recent`, { query: { limit } });
390
+ }
391
+ if (!res.ok) {
392
+ return errorEnvelope({
393
+ code: mapHttpToCode(res.status),
394
+ message: `Roster transfers fetch failed: ${res.status} on ${path}`,
395
+ game: 'cs2',
396
+ source: 'cs2_roster_transfers',
397
+ requestId: reqId,
398
+ httpStatus: res.status,
399
+ });
400
+ }
401
+ const payload = unwrap(res.data);
402
+ return successEnvelope({
403
+ data: payload,
404
+ game: 'cs2',
405
+ source: 'cs2_roster_transfers',
406
+ requestId: reqId,
407
+ rateLimit: res.headers,
408
+ });
409
+ },
410
+ };
411
+ /** All curated CS2 tools */
412
+ export const cs2Tools = [
413
+ cs2LiveScoreboard,
414
+ cs2RoundEconomy,
415
+ cs2OpeningDuels,
416
+ cs2Clutches,
417
+ cs2TeamMapStats,
418
+ cs2UtilityLeaderboard,
419
+ cs2VetoSequence,
420
+ cs2RosterTransfers,
421
+ ];
@@ -5,8 +5,11 @@ import { matchTools } from './match.js';
5
5
  import { playerTools } from './player.js';
6
6
  import { teamTools } from './team.js';
7
7
  import { standingsTools } from './standings.js';
8
+ import { rankingsTools } from './rankings.js';
9
+ import { leaderboardTools } from './leaderboard.js';
8
10
  import { insightTools } from './insight.js';
9
- /** Curated outcome-tool catalog (16 tools). Order matches preferred cold-start ladder. */
11
+ import { cs2Tools } from './cs2.js';
12
+ /** Curated outcome-tool catalog. Order matches preferred cold-start ladder. */
10
13
  export const allTools = [
11
14
  ...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
12
15
  ...resolveTools,
@@ -15,7 +18,10 @@ export const allTools = [
15
18
  ...playerTools,
16
19
  ...teamTools,
17
20
  ...standingsTools,
21
+ ...rankingsTools,
22
+ ...leaderboardTools,
18
23
  ...insightTools,
24
+ ...cs2Tools,
19
25
  // Escape-hatch pair last: discover routes, then call one.
20
26
  ...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
21
27
  ];
@@ -291,6 +291,7 @@ Prefer match_summary when match is completed; match_details for live in-game.
291
291
  Do not use when: user wants final score/recap of a finished match.
292
292
 
293
293
  Parallel-safe: yes. Upstream cost: 4–8.
294
+ Tennis H2H accepts an optional surface filter (Hard, Clay, Grass).
294
295
  Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
295
296
  inputSchema: {
296
297
  type: 'object',
@@ -305,6 +306,7 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
305
306
  includeH2H: boolSchema('Include composed H2H stub.', true),
306
307
  includeRosters: boolSchema('Include roster snippets when available.', true),
307
308
  recentLimit: limitSchema({ default: 5, max: 15, description: 'Form window per side (default 5, max 15).' }),
309
+ surface: stringSchema('Optional tennis H2H surface filter: Hard, Clay, or Grass. Ignored for other games.', 'Clay'),
308
310
  },
309
311
  },
310
312
  handler: async (args, ctx) => {
@@ -329,6 +331,26 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
329
331
  const includeH2H = args.includeH2H !== false;
330
332
  const includeRosters = args.includeRosters !== false;
331
333
  const recentLimit = clampInt(args.recentLimit, 5, 1, 15);
334
+ // Optional tennis H2H surface filter (TENNIS-04 follow-through: the
335
+ // /tennis/h2h endpoint accepts surface=Hard|Clay|Grass). Canonicalise
336
+ // case so 'clay'/'CLAY' still hit; anything else is a validation error
337
+ // rather than a silently ignored filter.
338
+ const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
339
+ let surface;
340
+ if (surfaceRaw) {
341
+ const canon = surfaceRaw.charAt(0).toUpperCase() + surfaceRaw.slice(1).toLowerCase();
342
+ if (game === 'tennis' && canon !== 'Hard' && canon !== 'Clay' && canon !== 'Grass') {
343
+ return errorEnvelope({
344
+ code: 'VALIDATION',
345
+ message: `surface must be one of Hard, Clay, Grass (got '${surfaceRaw}')`,
346
+ game,
347
+ source: 'match_preview',
348
+ requestId,
349
+ tookMs: Date.now() - started,
350
+ });
351
+ }
352
+ surface = canon;
353
+ }
332
354
  if (!matchId && (!teamA || !teamB)) {
333
355
  return errorEnvelope({
334
356
  code: 'VALIDATION',
@@ -469,7 +491,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
469
491
  // Tennis has a first-class H2H endpoint with the full rivalry; use it
470
492
  // rather than composing from a short form window, which reported
471
493
  // Sinner vs Alcaraz as never having met.
472
- const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: { player1_id: teamA, player2_id: teamB } });
494
+ const h2hQuery = { player1_id: teamA, player2_id: teamB };
495
+ if (game === 'tennis' && surface)
496
+ h2hQuery.surface = surface;
497
+ const h2hRes = await fetchJson(ctx, '/tennis/h2h', { query: h2hQuery });
473
498
  upstreamCalls += 1;
474
499
  rateLimit = { ...rateLimit, ...h2hRes.headers };
475
500
  if (h2hRes.ok) {
@@ -594,6 +619,8 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
594
619
  talkingPoints.push(`${sideBName} key names: ${names.join(', ')}`);
595
620
  }
596
621
  const h2hRec = asRecord(h2h);
622
+ if (game === 'tennis' && surface && includeH2H)
623
+ talkingPoints.push(`H2H filtered to ${surface} courts`);
597
624
  if (h2hRec?.recordNote)
598
625
  talkingPoints.push(String(h2hRec.recordNote));
599
626
  else if (Array.isArray(h2hRec?.meetings) && h2hRec.meetings.length) {