cito-mcp 0.3.20 → 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/dist/tools/cs2.js +421 -0
- package/dist/tools/index.js +5 -1
- package/dist/tools/leaderboard.js +427 -0
- package/dist/tools/meta.js +181 -1
- package/dist/tools/team.js +183 -1
- package/package.json +2 -2
|
@@ -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
|
+
];
|
package/dist/tools/index.js
CHANGED
|
@@ -6,8 +6,10 @@ import { playerTools } from './player.js';
|
|
|
6
6
|
import { teamTools } from './team.js';
|
|
7
7
|
import { standingsTools } from './standings.js';
|
|
8
8
|
import { rankingsTools } from './rankings.js';
|
|
9
|
+
import { leaderboardTools } from './leaderboard.js';
|
|
9
10
|
import { insightTools } from './insight.js';
|
|
10
|
-
|
|
11
|
+
import { cs2Tools } from './cs2.js';
|
|
12
|
+
/** Curated outcome-tool catalog. Order matches preferred cold-start ladder. */
|
|
11
13
|
export const allTools = [
|
|
12
14
|
...metaTools.filter((t) => t.name === 'list_capabilities' || t.name === 'api_health'),
|
|
13
15
|
...resolveTools,
|
|
@@ -17,7 +19,9 @@ export const allTools = [
|
|
|
17
19
|
...teamTools,
|
|
18
20
|
...standingsTools,
|
|
19
21
|
...rankingsTools,
|
|
22
|
+
...leaderboardTools,
|
|
20
23
|
...insightTools,
|
|
24
|
+
...cs2Tools,
|
|
21
25
|
// Escape-hatch pair last: discover routes, then call one.
|
|
22
26
|
...metaTools.filter((t) => t.name === 'list_routes' || t.name === 'call_api'),
|
|
23
27
|
];
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* leaderboard_aces (TENNIS-18 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Tennis-only: season aces leaderboard from real match_stats aggregates
|
|
5
|
+
* (GET /tennis/leaderboards/aces). Upstream shape (verified 2026-09-09):
|
|
6
|
+
* { season, tour, stat: 'aces', items: [{ player_id, full_name, aces, matches }], total }.
|
|
7
|
+
* Upstream defaults: season=2026, tour=null (both tours combined), and it
|
|
8
|
+
* 422s on a bad tour or a non-year season — both surface as VALIDATION here.
|
|
9
|
+
*/
|
|
10
|
+
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
11
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
12
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
13
|
+
const DEFAULT_SEASON = 2026;
|
|
14
|
+
const MIN_SEASON = 1968;
|
|
15
|
+
const MAX_SEASON = 2100;
|
|
16
|
+
function parseOptionalTour(args) {
|
|
17
|
+
const rawTour = typeof args.tour === 'string' ? args.tour.trim() : '';
|
|
18
|
+
const rawDivision = typeof args.division === 'string' ? args.division.trim() : '';
|
|
19
|
+
if (rawTour && rawDivision && rawTour.toUpperCase() !== rawDivision.toUpperCase()) {
|
|
20
|
+
return { error: `tour ('${rawTour}') and division ('${rawDivision}') disagree; pass one of ATP|WTA` };
|
|
21
|
+
}
|
|
22
|
+
const raw = rawTour || rawDivision;
|
|
23
|
+
if (!raw)
|
|
24
|
+
return {};
|
|
25
|
+
const upper = raw.toUpperCase();
|
|
26
|
+
if (upper !== 'ATP' && upper !== 'WTA') {
|
|
27
|
+
return { error: `tour must be ATP or WTA (got '${raw}')` };
|
|
28
|
+
}
|
|
29
|
+
return { tour: upper };
|
|
30
|
+
}
|
|
31
|
+
function parseSeason(args) {
|
|
32
|
+
const raw = args.season;
|
|
33
|
+
if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) {
|
|
34
|
+
return { season: DEFAULT_SEASON };
|
|
35
|
+
}
|
|
36
|
+
const num = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
37
|
+
if (!Number.isInteger(num) || num < MIN_SEASON || num > MAX_SEASON) {
|
|
38
|
+
return { error: `season must be a YYYY year ${MIN_SEASON}-${MAX_SEASON} (got '${String(raw)}')` };
|
|
39
|
+
}
|
|
40
|
+
return { season: num };
|
|
41
|
+
}
|
|
42
|
+
function normalizeAceRow(row) {
|
|
43
|
+
const r = asRecord(row) ?? {};
|
|
44
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
45
|
+
return {
|
|
46
|
+
playerId: pickString(r.player_id) ?? null,
|
|
47
|
+
playerName: pickString(r.full_name, r.player_name) ?? null,
|
|
48
|
+
aces: num(r.aces),
|
|
49
|
+
matches: num(r.matches),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export const leaderboardAces = {
|
|
53
|
+
name: 'leaderboard_aces',
|
|
54
|
+
description: `Tennis season aces leaderboard: most aces served in a season, from real match-stats aggregates.
|
|
55
|
+
|
|
56
|
+
When to use:
|
|
57
|
+
- Who leads the tour in aces this season; season serving-leader tables.
|
|
58
|
+
|
|
59
|
+
Prefer over: raw leaderboard via call_api for agent-normalized rows.
|
|
60
|
+
|
|
61
|
+
Do not use when: ranking position → standings with game tennis; week-over-week movement → rankings_movers; one player's recent form → player_form.
|
|
62
|
+
|
|
63
|
+
Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. Rows carry aces + matches played, ordered most aces first.
|
|
64
|
+
|
|
65
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
required: ['game'],
|
|
70
|
+
properties: {
|
|
71
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
72
|
+
tour: stringSchema('ATP or WTA tour; omit for the combined board. Synonym: division.', 'WTA'),
|
|
73
|
+
division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
|
|
74
|
+
season: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
minimum: MIN_SEASON,
|
|
77
|
+
maximum: MAX_SEASON,
|
|
78
|
+
default: DEFAULT_SEASON,
|
|
79
|
+
description: 'Season year (YYYY). Default 2026.',
|
|
80
|
+
},
|
|
81
|
+
limit: limitSchema({ default: 10, max: 100 }),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
handler: async (args, ctx) => {
|
|
85
|
+
const started = Date.now();
|
|
86
|
+
const requestId = newRequestId();
|
|
87
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
88
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
89
|
+
return errorEnvelope({
|
|
90
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
91
|
+
message: gameParse.error ?? 'game is required',
|
|
92
|
+
game: null,
|
|
93
|
+
source: 'leaderboard_aces',
|
|
94
|
+
requestId,
|
|
95
|
+
tookMs: Date.now() - started,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const game = gameParse.game;
|
|
99
|
+
if (game !== 'tennis') {
|
|
100
|
+
return errorEnvelope({
|
|
101
|
+
code: 'NOT_IMPLEMENTED',
|
|
102
|
+
message: `leaderboard_aces is tennis-only (game '${game}' has no aces leaderboard)`,
|
|
103
|
+
game,
|
|
104
|
+
source: 'leaderboard_aces',
|
|
105
|
+
requestId,
|
|
106
|
+
tookMs: Date.now() - started,
|
|
107
|
+
recover: [
|
|
108
|
+
'Use standings for the latest snapshot of this game',
|
|
109
|
+
'Retry leaderboard_aces with game tennis and an optional tour ATP|WTA',
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const tourParse = parseOptionalTour(args);
|
|
114
|
+
if (tourParse.error) {
|
|
115
|
+
return errorEnvelope({
|
|
116
|
+
code: 'VALIDATION',
|
|
117
|
+
message: tourParse.error,
|
|
118
|
+
game,
|
|
119
|
+
source: 'leaderboard_aces',
|
|
120
|
+
requestId,
|
|
121
|
+
tookMs: Date.now() - started,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const seasonParse = parseSeason(args);
|
|
125
|
+
if (seasonParse.error || seasonParse.season === undefined) {
|
|
126
|
+
return errorEnvelope({
|
|
127
|
+
code: 'VALIDATION',
|
|
128
|
+
message: seasonParse.error ?? 'season is invalid',
|
|
129
|
+
game,
|
|
130
|
+
source: 'leaderboard_aces',
|
|
131
|
+
requestId,
|
|
132
|
+
tookMs: Date.now() - started,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const limit = clampInt(args.limit, 10, 1, 100);
|
|
136
|
+
const res = await fetchJson(ctx, '/tennis/leaderboards/aces', {
|
|
137
|
+
query: {
|
|
138
|
+
season: seasonParse.season,
|
|
139
|
+
limit,
|
|
140
|
+
...(tourParse.tour ? { tour: tourParse.tour } : {}),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
return errorEnvelope({
|
|
145
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
146
|
+
message: `Aces leaderboard failed (HTTP ${res.status})`,
|
|
147
|
+
game,
|
|
148
|
+
source: 'leaderboard_aces',
|
|
149
|
+
requestId,
|
|
150
|
+
tookMs: Date.now() - started,
|
|
151
|
+
upstreamCalls: 1,
|
|
152
|
+
httpStatus: res.status,
|
|
153
|
+
rateLimit: res.headers,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const root = asRecord(res.data);
|
|
157
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
158
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeAceRow);
|
|
159
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
160
|
+
return successEnvelope({
|
|
161
|
+
pagination: {
|
|
162
|
+
limit,
|
|
163
|
+
offset: 0,
|
|
164
|
+
total,
|
|
165
|
+
hasMore: false,
|
|
166
|
+
nextCursor: null,
|
|
167
|
+
prevCursor: null,
|
|
168
|
+
},
|
|
169
|
+
source: 'leaderboard_aces',
|
|
170
|
+
game,
|
|
171
|
+
requestId,
|
|
172
|
+
tookMs: Date.now() - started,
|
|
173
|
+
upstreamCalls: 1,
|
|
174
|
+
rateLimit: res.headers,
|
|
175
|
+
data: {
|
|
176
|
+
title: `${tourParse.tour ?? 'Combined'} aces leaderboard ${seasonParse.season}`,
|
|
177
|
+
season: typeof body.season === 'number' ? body.season : seasonParse.season,
|
|
178
|
+
tour: pickString(body.tour) ?? tourParse.tour ?? null,
|
|
179
|
+
stat: pickString(body.stat) ?? 'aces',
|
|
180
|
+
items,
|
|
181
|
+
total,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
function normalizePctRow(numKey, denKey) {
|
|
187
|
+
return (row) => {
|
|
188
|
+
const r = asRecord(row) ?? {};
|
|
189
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
190
|
+
return {
|
|
191
|
+
playerId: pickString(r.player_id) ?? null,
|
|
192
|
+
playerName: pickString(r.full_name, r.player_name) ?? null,
|
|
193
|
+
attempts: num(r[numKey]),
|
|
194
|
+
total: num(r[denKey]),
|
|
195
|
+
pct: num(r.pct),
|
|
196
|
+
matches: num(r.matches),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function makePctLeaderboardTool(spec) {
|
|
201
|
+
const normalizeRow = normalizePctRow(spec.numKey, spec.denKey);
|
|
202
|
+
return {
|
|
203
|
+
name: spec.name,
|
|
204
|
+
description: `Tennis season ${spec.titleNoun} leaderboard: ${spec.blurb}
|
|
205
|
+
|
|
206
|
+
When to use:
|
|
207
|
+
- ${spec.blurb} season tables; clutch/serve-efficiency leader queries.
|
|
208
|
+
|
|
209
|
+
Prefer over: raw leaderboard via call_api for agent-normalized rows.
|
|
210
|
+
|
|
211
|
+
Do not use when: ranking position → standings with game tennis; week-over-week movement → rankings_movers; one player's recent form → player_form.
|
|
212
|
+
|
|
213
|
+
Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. ${spec.minAttemptsNote}
|
|
214
|
+
|
|
215
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
required: ['game'],
|
|
220
|
+
properties: {
|
|
221
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
222
|
+
tour: stringSchema('ATP or WTA tour; omit for the combined board. Synonym: division.', 'WTA'),
|
|
223
|
+
division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
|
|
224
|
+
season: {
|
|
225
|
+
type: 'integer',
|
|
226
|
+
minimum: MIN_SEASON,
|
|
227
|
+
maximum: MAX_SEASON,
|
|
228
|
+
default: DEFAULT_SEASON,
|
|
229
|
+
description: 'Season year (YYYY). Default 2026.',
|
|
230
|
+
},
|
|
231
|
+
limit: limitSchema({ default: 10, max: 100 }),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
handler: async (args, ctx) => {
|
|
235
|
+
const started = Date.now();
|
|
236
|
+
const requestId = newRequestId();
|
|
237
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
238
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
239
|
+
return errorEnvelope({
|
|
240
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
241
|
+
message: gameParse.error ?? 'game is required',
|
|
242
|
+
game: null,
|
|
243
|
+
source: spec.name,
|
|
244
|
+
requestId,
|
|
245
|
+
tookMs: Date.now() - started,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const game = gameParse.game;
|
|
249
|
+
if (game !== 'tennis') {
|
|
250
|
+
return errorEnvelope({
|
|
251
|
+
code: 'NOT_IMPLEMENTED',
|
|
252
|
+
message: `${spec.name} is tennis-only (game '${game}' has no ${spec.titleNoun} leaderboard)`,
|
|
253
|
+
game,
|
|
254
|
+
source: spec.name,
|
|
255
|
+
requestId,
|
|
256
|
+
tookMs: Date.now() - started,
|
|
257
|
+
recover: [
|
|
258
|
+
'Use standings for the latest snapshot of this game',
|
|
259
|
+
`Retry ${spec.name} with game tennis and an optional tour ATP|WTA`,
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const tourParse = parseOptionalTour(args);
|
|
264
|
+
if (tourParse.error) {
|
|
265
|
+
return errorEnvelope({
|
|
266
|
+
code: 'VALIDATION',
|
|
267
|
+
message: tourParse.error,
|
|
268
|
+
game,
|
|
269
|
+
source: spec.name,
|
|
270
|
+
requestId,
|
|
271
|
+
tookMs: Date.now() - started,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const seasonParse = parseSeason(args);
|
|
275
|
+
if (seasonParse.error || seasonParse.season === undefined) {
|
|
276
|
+
return errorEnvelope({
|
|
277
|
+
code: 'VALIDATION',
|
|
278
|
+
message: seasonParse.error ?? 'season is invalid',
|
|
279
|
+
game,
|
|
280
|
+
source: spec.name,
|
|
281
|
+
requestId,
|
|
282
|
+
tookMs: Date.now() - started,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const limit = clampInt(args.limit, 10, 1, 100);
|
|
286
|
+
const res = await fetchJson(ctx, `/tennis${spec.path}`, {
|
|
287
|
+
query: {
|
|
288
|
+
season: seasonParse.season,
|
|
289
|
+
limit,
|
|
290
|
+
...(tourParse.tour ? { tour: tourParse.tour } : {}),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
return errorEnvelope({
|
|
295
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
296
|
+
message: `${spec.titleNoun} leaderboard failed (HTTP ${res.status})`,
|
|
297
|
+
game,
|
|
298
|
+
source: spec.name,
|
|
299
|
+
requestId,
|
|
300
|
+
tookMs: Date.now() - started,
|
|
301
|
+
upstreamCalls: 1,
|
|
302
|
+
httpStatus: res.status,
|
|
303
|
+
rateLimit: res.headers,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const root = asRecord(res.data);
|
|
307
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
308
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeRow);
|
|
309
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
310
|
+
return successEnvelope({
|
|
311
|
+
pagination: {
|
|
312
|
+
limit,
|
|
313
|
+
offset: 0,
|
|
314
|
+
total,
|
|
315
|
+
hasMore: false,
|
|
316
|
+
nextCursor: null,
|
|
317
|
+
prevCursor: null,
|
|
318
|
+
},
|
|
319
|
+
source: spec.name,
|
|
320
|
+
game,
|
|
321
|
+
requestId,
|
|
322
|
+
tookMs: Date.now() - started,
|
|
323
|
+
upstreamCalls: 1,
|
|
324
|
+
rateLimit: res.headers,
|
|
325
|
+
data: {
|
|
326
|
+
title: `${tourParse.tour ?? 'Combined'} ${spec.titleNoun} leaderboard ${seasonParse.season}`,
|
|
327
|
+
season: typeof body.season === 'number' ? body.season : seasonParse.season,
|
|
328
|
+
tour: pickString(body.tour) ?? tourParse.tour ?? null,
|
|
329
|
+
stat: pickString(body.stat) ?? spec.stat,
|
|
330
|
+
items,
|
|
331
|
+
total,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export const leaderboardBreakPointsSaved = makePctLeaderboardTool({
|
|
338
|
+
name: 'leaderboard_break_points_saved',
|
|
339
|
+
path: '/leaderboards/break-points-saved',
|
|
340
|
+
stat: 'break_points_saved',
|
|
341
|
+
numKey: 'break_points_saved',
|
|
342
|
+
denKey: 'break_points_faced',
|
|
343
|
+
titleNoun: 'break-points-saved',
|
|
344
|
+
blurb: 'Share of break points saved per player, from real match-stats aggregates, ordered highest pct first.',
|
|
345
|
+
minAttemptsNote: 'Only players facing at least 50 break points qualify.',
|
|
346
|
+
});
|
|
347
|
+
export const leaderboardFirstServeWon = makePctLeaderboardTool({
|
|
348
|
+
name: 'leaderboard_first_serve_won',
|
|
349
|
+
path: '/leaderboards/1st-serve-won',
|
|
350
|
+
stat: 'first_serve_won',
|
|
351
|
+
numKey: 'first_serve_points_won',
|
|
352
|
+
denKey: 'first_serves_in',
|
|
353
|
+
titleNoun: 'first-serve-won',
|
|
354
|
+
blurb: 'Share of first-serve points won per player, from real match-stats aggregates, ordered highest pct first.',
|
|
355
|
+
minAttemptsNote: 'Only players with at least 300 first serves in qualify.',
|
|
356
|
+
});
|
|
357
|
+
export const leaderboardTiebreakWinPct = makePctLeaderboardTool({
|
|
358
|
+
name: 'leaderboard_tiebreak_win_pct',
|
|
359
|
+
path: '/leaderboards/tiebreak-win-pct',
|
|
360
|
+
stat: 'tiebreak_win_pct',
|
|
361
|
+
numKey: 'tiebreaks_won',
|
|
362
|
+
denKey: 'tiebreaks_played',
|
|
363
|
+
titleNoun: 'tiebreak-win-pct',
|
|
364
|
+
blurb: 'Share of classic 7-point tiebreaks won per player, from real match-set aggregates, ordered highest pct first.',
|
|
365
|
+
minAttemptsNote: 'Only players contesting at least 10 tiebreaks qualify.',
|
|
366
|
+
});
|
|
367
|
+
export const leaderboardBreakConversion = makePctLeaderboardTool({
|
|
368
|
+
name: 'leaderboard_break_conversion',
|
|
369
|
+
path: '/leaderboards/break-conversion',
|
|
370
|
+
stat: 'break_conversion',
|
|
371
|
+
numKey: 'break_points_won',
|
|
372
|
+
denKey: 'break_opportunities',
|
|
373
|
+
titleNoun: 'break-conversion',
|
|
374
|
+
blurb: 'Share of break opportunities converted per player, from real match-stats aggregates read return-side, ordered highest pct first.',
|
|
375
|
+
minAttemptsNote: 'Only players with at least 50 break opportunities qualify.',
|
|
376
|
+
});
|
|
377
|
+
export const leaderboardReturnGamesWon = makePctLeaderboardTool({
|
|
378
|
+
name: 'leaderboard_return_games_won',
|
|
379
|
+
path: '/leaderboards/return-games-won',
|
|
380
|
+
stat: 'return_games_won',
|
|
381
|
+
numKey: 'return_games_won',
|
|
382
|
+
denKey: 'return_games_played',
|
|
383
|
+
titleNoun: 'return-games-won',
|
|
384
|
+
blurb: 'Share of return games won per player (breaks over opponent service games played), from real match-stats aggregates read return-side, ordered highest pct first.',
|
|
385
|
+
minAttemptsNote: 'Only players with at least 100 return games played qualify.',
|
|
386
|
+
});
|
|
387
|
+
export const leaderboardFinalsRecord = makePctLeaderboardTool({
|
|
388
|
+
name: 'leaderboard_finals_record',
|
|
389
|
+
path: '/leaderboards/finals-record',
|
|
390
|
+
stat: 'finals_record',
|
|
391
|
+
numKey: 'finals_won',
|
|
392
|
+
denKey: 'finals_played',
|
|
393
|
+
titleNoun: 'finals-record',
|
|
394
|
+
blurb: 'Share of finals won (titles clutch) per player, from real completed finals rows, ordered highest pct first.',
|
|
395
|
+
minAttemptsNote: 'Only players contesting at least 3 finals qualify.',
|
|
396
|
+
});
|
|
397
|
+
export const leaderboardComebackWins = makePctLeaderboardTool({
|
|
398
|
+
name: 'leaderboard_comeback_wins',
|
|
399
|
+
path: '/leaderboards/comeback-wins',
|
|
400
|
+
stat: 'comeback_wins',
|
|
401
|
+
numKey: 'comebacks_won',
|
|
402
|
+
denKey: 'matches_with_first_set',
|
|
403
|
+
titleNoun: 'comeback-wins',
|
|
404
|
+
blurb: 'Share of matches won after losing the first set per player, from real first-set rows joined to completed matches, ordered highest pct first.',
|
|
405
|
+
minAttemptsNote: 'Only players with first-set data on at least 20 matches qualify.',
|
|
406
|
+
});
|
|
407
|
+
export const leaderboardDecidingSetRecord = makePctLeaderboardTool({
|
|
408
|
+
name: 'leaderboard_deciding_set_record',
|
|
409
|
+
path: '/leaderboards/deciding-set-record',
|
|
410
|
+
stat: 'deciding_set_record',
|
|
411
|
+
numKey: 'deciders_won',
|
|
412
|
+
denKey: 'deciders_played',
|
|
413
|
+
titleNoun: 'deciding-set-record',
|
|
414
|
+
blurb: 'Share of deciding sets (full-length final sets) won per player, from real match rows, ordered highest pct first.',
|
|
415
|
+
minAttemptsNote: 'Only players contesting at least 10 deciders qualify.',
|
|
416
|
+
});
|
|
417
|
+
export const leaderboardTools = [
|
|
418
|
+
leaderboardAces,
|
|
419
|
+
leaderboardBreakPointsSaved,
|
|
420
|
+
leaderboardFirstServeWon,
|
|
421
|
+
leaderboardTiebreakWinPct,
|
|
422
|
+
leaderboardBreakConversion,
|
|
423
|
+
leaderboardReturnGamesWon,
|
|
424
|
+
leaderboardDecidingSetRecord,
|
|
425
|
+
leaderboardComebackWins,
|
|
426
|
+
leaderboardFinalsRecord,
|
|
427
|
+
];
|
package/dist/tools/meta.js
CHANGED
|
@@ -116,6 +116,16 @@ const TOOL_CATALOG = [
|
|
|
116
116
|
preferOver: ['agent-side double match-list filtering'],
|
|
117
117
|
doNotUse: 'Single-side form only → team_profile or player_profile',
|
|
118
118
|
},
|
|
119
|
+
{
|
|
120
|
+
name: 'h2h_matrix',
|
|
121
|
+
outcome: 'Tennis multi-player H2H grid: every pair series record in one matrix',
|
|
122
|
+
parallelSafe: true,
|
|
123
|
+
games: ['tennis'],
|
|
124
|
+
jobs: ['h2h', 'preview'],
|
|
125
|
+
exampleArgs: { game: 'tennis', players: ['atp_207989', 'atp_100644'] },
|
|
126
|
+
preferOver: ['N head_to_head calls for an N-player field', 'raw matrix via call_api'],
|
|
127
|
+
doNotUse: 'Two-player deep-dive with tiebreak splits → head_to_head; season leaders → leaderboard_*',
|
|
128
|
+
},
|
|
119
129
|
{
|
|
120
130
|
name: 'standings',
|
|
121
131
|
outcome: 'League/event standings or world/division rankings',
|
|
@@ -146,6 +156,96 @@ const TOOL_CATALOG = [
|
|
|
146
156
|
preferOver: ['raw form via call_api'],
|
|
147
157
|
doNotUse: 'Career totals/titles → player_profile; ranking deltas → rankings_movers',
|
|
148
158
|
},
|
|
159
|
+
{
|
|
160
|
+
name: 'leaderboard_aces',
|
|
161
|
+
outcome: 'Tennis season aces leaderboard: most aces served, ordered first',
|
|
162
|
+
parallelSafe: true,
|
|
163
|
+
games: ['tennis'],
|
|
164
|
+
jobs: ['standings'],
|
|
165
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
166
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
167
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'leaderboard_break_points_saved',
|
|
171
|
+
outcome: 'Tennis season break-points-saved pct leaderboard, min 50 faced',
|
|
172
|
+
parallelSafe: true,
|
|
173
|
+
games: ['tennis'],
|
|
174
|
+
jobs: ['standings'],
|
|
175
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
176
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
177
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'leaderboard_first_serve_won',
|
|
181
|
+
outcome: 'Tennis season first-serve-won pct leaderboard, min 300 in',
|
|
182
|
+
parallelSafe: true,
|
|
183
|
+
games: ['tennis'],
|
|
184
|
+
jobs: ['standings'],
|
|
185
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
186
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
187
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'leaderboard_tiebreak_win_pct',
|
|
191
|
+
outcome: 'Tennis season tiebreak-win-pct leaderboard, min 10 tiebreaks',
|
|
192
|
+
parallelSafe: true,
|
|
193
|
+
games: ['tennis'],
|
|
194
|
+
jobs: ['standings'],
|
|
195
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
196
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
197
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'leaderboard_break_conversion',
|
|
201
|
+
outcome: 'Tennis season break-conversion pct leaderboard, min 50 opportunities',
|
|
202
|
+
parallelSafe: true,
|
|
203
|
+
games: ['tennis'],
|
|
204
|
+
jobs: ['standings'],
|
|
205
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
206
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
207
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'leaderboard_return_games_won',
|
|
211
|
+
outcome: 'Tennis season return-games-won pct leaderboard, min 100 played',
|
|
212
|
+
parallelSafe: true,
|
|
213
|
+
games: ['tennis'],
|
|
214
|
+
jobs: ['standings'],
|
|
215
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
216
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
217
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'leaderboard_deciding_set_record',
|
|
221
|
+
outcome: 'Tennis season deciding-set-record pct leaderboard, min 10 deciders',
|
|
222
|
+
parallelSafe: true,
|
|
223
|
+
games: ['tennis'],
|
|
224
|
+
jobs: ['standings'],
|
|
225
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
226
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
227
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'leaderboard_comeback_wins',
|
|
231
|
+
outcome: 'Tennis season comeback-wins pct leaderboard, min 20 first sets',
|
|
232
|
+
parallelSafe: true,
|
|
233
|
+
games: ['tennis'],
|
|
234
|
+
jobs: ['standings'],
|
|
235
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
236
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
237
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'leaderboard_finals_record',
|
|
241
|
+
outcome: 'Tennis season finals-record pct leaderboard, min 3 finals',
|
|
242
|
+
parallelSafe: true,
|
|
243
|
+
games: ['tennis'],
|
|
244
|
+
jobs: ['standings'],
|
|
245
|
+
exampleArgs: { game: 'tennis', tour: 'ATP', season: 2026, limit: 10 },
|
|
246
|
+
preferOver: ['raw leaderboard via call_api'],
|
|
247
|
+
doNotUse: 'Ranking position → standings; week-over-week movement → rankings_movers',
|
|
248
|
+
},
|
|
149
249
|
{
|
|
150
250
|
name: 'match_preview',
|
|
151
251
|
outcome: 'Pre-match briefing: sides, rosters/form, H2H stub',
|
|
@@ -172,6 +272,86 @@ const TOOL_CATALOG = [
|
|
|
172
272
|
],
|
|
173
273
|
doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
|
|
174
274
|
},
|
|
275
|
+
{
|
|
276
|
+
name: 'cs2_live_scoreboard',
|
|
277
|
+
outcome: 'Live real-time in-game scoreboard for an active CS2 match',
|
|
278
|
+
parallelSafe: true,
|
|
279
|
+
games: ['cs2'],
|
|
280
|
+
jobs: ['live_board', 'match_page'],
|
|
281
|
+
exampleArgs: { matchId: 'cs2-match-2397733' },
|
|
282
|
+
preferOver: ['polling call_api for live scoreboard'],
|
|
283
|
+
doNotUse: 'For completed matches without live state',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: 'cs2_round_economy',
|
|
287
|
+
outcome: 'Round-by-round tactical economy and buy types for a CS2 map',
|
|
288
|
+
parallelSafe: true,
|
|
289
|
+
games: ['cs2'],
|
|
290
|
+
jobs: ['match_page'],
|
|
291
|
+
exampleArgs: { mapId: '7716-map-1' },
|
|
292
|
+
preferOver: ['raw round event parsing'],
|
|
293
|
+
doNotUse: 'For overall match scorelines → match_summary',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'cs2_opening_duels',
|
|
297
|
+
outcome: 'First blood and opening duel statistics by player or leaderboard',
|
|
298
|
+
parallelSafe: true,
|
|
299
|
+
games: ['cs2'],
|
|
300
|
+
jobs: ['player_form', 'match_page'],
|
|
301
|
+
exampleArgs: { playerId: 'chucky', limit: 20 },
|
|
302
|
+
preferOver: ['manual kill log counting'],
|
|
303
|
+
doNotUse: 'For general player stats → player_profile',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'cs2_clutches',
|
|
307
|
+
outcome: '1vX clutch situation success records (1v1 to 1v5)',
|
|
308
|
+
parallelSafe: true,
|
|
309
|
+
games: ['cs2'],
|
|
310
|
+
jobs: ['player_form', 'match_page'],
|
|
311
|
+
exampleArgs: { playerId: 'xm1nd', limit: 20 },
|
|
312
|
+
preferOver: ['manual demo parse analysis'],
|
|
313
|
+
doNotUse: 'For regular player stats → player_profile',
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'cs2_team_map_stats',
|
|
317
|
+
outcome: 'Team win rates and CT/T side splits across competitive map pool',
|
|
318
|
+
parallelSafe: true,
|
|
319
|
+
games: ['cs2'],
|
|
320
|
+
jobs: ['team_page', 'preview'],
|
|
321
|
+
exampleArgs: { teamId: 'hltv-team-4608', days: 90 },
|
|
322
|
+
preferOver: ['manual match history scraping'],
|
|
323
|
+
doNotUse: 'For non-CS2 games',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: 'cs2_utility_leaderboard',
|
|
327
|
+
outcome: 'Sub-second leaderboard of CS2 utility and flashbang efficiency',
|
|
328
|
+
parallelSafe: true,
|
|
329
|
+
games: ['cs2'],
|
|
330
|
+
jobs: ['standings', 'player_form'],
|
|
331
|
+
exampleArgs: { limit: 20 },
|
|
332
|
+
preferOver: ['unindexed round event aggregation'],
|
|
333
|
+
doNotUse: 'For standard weapon ratings',
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: 'cs2_veto_sequence',
|
|
337
|
+
outcome: 'Map pick/ban sequence and veto history for a CS2 match',
|
|
338
|
+
parallelSafe: true,
|
|
339
|
+
games: ['cs2'],
|
|
340
|
+
jobs: ['match_page', 'preview'],
|
|
341
|
+
exampleArgs: { matchId: '2397603' },
|
|
342
|
+
preferOver: ['scraping match page text'],
|
|
343
|
+
doNotUse: 'For map scores → match_summary',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'cs2_roster_transfers',
|
|
347
|
+
outcome: 'Recent pro CS2 roster changes, benchings, stand-ins, and transfers',
|
|
348
|
+
parallelSafe: true,
|
|
349
|
+
games: ['cs2'],
|
|
350
|
+
jobs: ['team_page', 'schedule'],
|
|
351
|
+
exampleArgs: { limit: 20 },
|
|
352
|
+
preferOver: ['manual news feed scanning'],
|
|
353
|
+
doNotUse: 'For current active rosters → team_profile',
|
|
354
|
+
},
|
|
175
355
|
{
|
|
176
356
|
name: 'list_routes',
|
|
177
357
|
outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
|
|
@@ -199,7 +379,7 @@ const JOBS = [
|
|
|
199
379
|
{ id: 'team_page', description: 'Team/org profile screen', recommendedTools: ['resolve_entity', 'team_profile'] },
|
|
200
380
|
{ id: 'player_form', description: 'Player/fighter form card', recommendedTools: ['resolve_entity', 'player_profile'] },
|
|
201
381
|
{ id: 'standings', description: 'Tables and rankings', recommendedTools: ['standings'] },
|
|
202
|
-
{ id: 'h2h', description: 'Historical rivalry record', recommendedTools: ['head_to_head'] },
|
|
382
|
+
{ id: 'h2h', description: 'Historical rivalry record', recommendedTools: ['head_to_head', 'h2h_matrix'] },
|
|
203
383
|
{ id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
|
|
204
384
|
{ id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
|
|
205
385
|
{ id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
|
package/dist/tools/team.js
CHANGED
|
@@ -1022,4 +1022,186 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
1022
1022
|
});
|
|
1023
1023
|
},
|
|
1024
1024
|
};
|
|
1025
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* h2h_matrix (TENNIS-24, cycle 60).
|
|
1027
|
+
*
|
|
1028
|
+
* Tennis-only: multi-player head-to-head comparison grid from the first-class
|
|
1029
|
+
* GET /tennis/h2h/matrix route (2-16 player ids, comma-separated). Upstream
|
|
1030
|
+
* shape (verified 2026-09-09): { players: [{ id, name, ioc, wins }], matrix:
|
|
1031
|
+
* { [rowId]: { [colId]: "W-L" | "-" } } }. Cell "7-6" at [A][B] means A won 7
|
|
1032
|
+
* and B won 6 of their meetings; "-" marks the self diagonal. Unknown ids get
|
|
1033
|
+
* honest 0-0 cells and no players entry (no 404); fewer than 2 or more than 16
|
|
1034
|
+
* ids 422, which surfaces as VALIDATION here.
|
|
1035
|
+
*
|
|
1036
|
+
* Names are resolved to atp_/wta_ ids via /tennis/players/search first (same
|
|
1037
|
+
* id-shaped shortcut as the head_to_head tennis branch); duplicates collapse
|
|
1038
|
+
* to one grid row.
|
|
1039
|
+
*/
|
|
1040
|
+
export const h2hMatrix = {
|
|
1041
|
+
name: 'h2h_matrix',
|
|
1042
|
+
description: `Multi-player tennis head-to-head grid: every pair's series record in one comparison matrix.
|
|
1043
|
+
|
|
1044
|
+
When to use:
|
|
1045
|
+
- Draw/field analysis: how each contender fares against every other (e.g. Alcaraz vs Zverev vs Sinner round-robin records)
|
|
1046
|
+
- Group-stage or semifinal-field comparisons
|
|
1047
|
+
|
|
1048
|
+
Prefer over: N head_to_head calls for an N-player field; raw matrix via call_api.
|
|
1049
|
+
|
|
1050
|
+
Do not use when: a two-player rivalry deep-dive with tiebreak/decider splits → head_to_head; season stat leaders → leaderboard_*; rankings → standings.
|
|
1051
|
+
|
|
1052
|
+
Tennis-only. players takes 2-16 ids or names (names resolve via player search). Each matrix cell is "W-L" from the row player's perspective, "-" on the diagonal.
|
|
1053
|
+
|
|
1054
|
+
Parallel-safe: yes. Upstream cost: 1 + one search per unresolved name.`,
|
|
1055
|
+
inputSchema: {
|
|
1056
|
+
type: 'object',
|
|
1057
|
+
additionalProperties: false,
|
|
1058
|
+
required: ['game', 'players'],
|
|
1059
|
+
properties: {
|
|
1060
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
1061
|
+
players: {
|
|
1062
|
+
type: 'array',
|
|
1063
|
+
items: { type: 'string' },
|
|
1064
|
+
minItems: 2,
|
|
1065
|
+
maxItems: 16,
|
|
1066
|
+
description: '2-16 tennis player ids (atp_207989) or names ("Carlos Alcaraz"); duplicates collapse.',
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
handler: async (args, ctx) => {
|
|
1071
|
+
const started = Date.now();
|
|
1072
|
+
const requestId = newRequestId();
|
|
1073
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
1074
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
1075
|
+
return errorEnvelope({
|
|
1076
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
1077
|
+
message: gameParse.error ?? 'game is required',
|
|
1078
|
+
game: null,
|
|
1079
|
+
source: 'h2h_matrix',
|
|
1080
|
+
requestId,
|
|
1081
|
+
tookMs: Date.now() - started,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const game = gameParse.game;
|
|
1085
|
+
if (game !== 'tennis') {
|
|
1086
|
+
return errorEnvelope({
|
|
1087
|
+
code: 'NOT_IMPLEMENTED',
|
|
1088
|
+
message: `h2h_matrix is tennis-only (game '${game}' has no H2H matrix)`,
|
|
1089
|
+
game,
|
|
1090
|
+
source: 'h2h_matrix',
|
|
1091
|
+
requestId,
|
|
1092
|
+
tookMs: Date.now() - started,
|
|
1093
|
+
recover: [
|
|
1094
|
+
'Use head_to_head for a two-side rivalry record in this game',
|
|
1095
|
+
'Retry h2h_matrix with game tennis and 2-16 player ids or names',
|
|
1096
|
+
],
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
const rawPlayers = Array.isArray(args.players) ? args.players : [];
|
|
1100
|
+
const cleaned = rawPlayers
|
|
1101
|
+
.filter((p) => typeof p === 'string' && p.trim().length > 0)
|
|
1102
|
+
.map((p) => p.trim());
|
|
1103
|
+
if (cleaned.length < 2 || cleaned.length > 16) {
|
|
1104
|
+
return errorEnvelope({
|
|
1105
|
+
code: 'VALIDATION',
|
|
1106
|
+
message: `players must contain between 2 and 16 names or ids (got ${cleaned.length})`,
|
|
1107
|
+
game,
|
|
1108
|
+
source: 'h2h_matrix',
|
|
1109
|
+
requestId,
|
|
1110
|
+
tookMs: Date.now() - started,
|
|
1111
|
+
recover: ['Pass 2-16 player ids (atp_207989) or names ("Carlos Alcaraz")'],
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
let upstreamCalls = 0;
|
|
1115
|
+
let rateLimit = {};
|
|
1116
|
+
const resolveTennisSide = async (side) => {
|
|
1117
|
+
if (/^(atp|wta)_\d+$/i.test(side))
|
|
1118
|
+
return { id: side, name: side };
|
|
1119
|
+
const res = await fetchJson(ctx, '/tennis/players/search', { query: { q: side, limit: 3 } });
|
|
1120
|
+
upstreamCalls += 1;
|
|
1121
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
1122
|
+
if (!res.ok)
|
|
1123
|
+
return null;
|
|
1124
|
+
const envelope = asRecord(res.data) ?? {};
|
|
1125
|
+
const data = asRecord(envelope.data) ?? envelope;
|
|
1126
|
+
const first = asRecord(extractRows(data.items ?? data)[0]);
|
|
1127
|
+
const id = pickString(first?.id, first?.player_id);
|
|
1128
|
+
return id ? { id, name: pickString(first?.full_name, first?.name) ?? side } : null;
|
|
1129
|
+
};
|
|
1130
|
+
const resolved = await Promise.all(cleaned.map((side) => resolveTennisSide(side)));
|
|
1131
|
+
const missing = cleaned.filter((_, i) => !resolved[i]);
|
|
1132
|
+
if (missing.length > 0) {
|
|
1133
|
+
return errorEnvelope({
|
|
1134
|
+
code: 'NOT_FOUND',
|
|
1135
|
+
message: `Tennis player(s) not found: ${missing.join(', ')} — pass names or ids like atp_104745`,
|
|
1136
|
+
game,
|
|
1137
|
+
source: 'h2h_matrix',
|
|
1138
|
+
requestId,
|
|
1139
|
+
tookMs: Date.now() - started,
|
|
1140
|
+
upstreamCalls,
|
|
1141
|
+
rateLimit,
|
|
1142
|
+
recover: [
|
|
1143
|
+
'resolve_entity { game: "tennis", type: "player", q } for each missing name',
|
|
1144
|
+
'Retry h2h_matrix with returned ids',
|
|
1145
|
+
],
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
// Dedupe resolved ids preserving order: REST dict keys would collapse
|
|
1149
|
+
// anyway, but players_info would list the duplicate twice.
|
|
1150
|
+
const ids = [...new Set(resolved.map((r) => r.id))];
|
|
1151
|
+
if (ids.length < 2) {
|
|
1152
|
+
return errorEnvelope({
|
|
1153
|
+
code: 'VALIDATION',
|
|
1154
|
+
message: 'players resolve to fewer than 2 distinct players — pass at least 2 different players',
|
|
1155
|
+
game,
|
|
1156
|
+
source: 'h2h_matrix',
|
|
1157
|
+
requestId,
|
|
1158
|
+
tookMs: Date.now() - started,
|
|
1159
|
+
upstreamCalls,
|
|
1160
|
+
rateLimit,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const res = await fetchJson(ctx, '/tennis/h2h/matrix', {
|
|
1164
|
+
query: { player_ids: ids.join(',') },
|
|
1165
|
+
});
|
|
1166
|
+
upstreamCalls += 1;
|
|
1167
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
1168
|
+
if (!res.ok) {
|
|
1169
|
+
return errorEnvelope({
|
|
1170
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
1171
|
+
message: `Tennis H2H matrix fetch failed (HTTP ${res.status})`,
|
|
1172
|
+
game,
|
|
1173
|
+
source: 'h2h_matrix',
|
|
1174
|
+
requestId,
|
|
1175
|
+
tookMs: Date.now() - started,
|
|
1176
|
+
upstreamCalls,
|
|
1177
|
+
httpStatus: res.status,
|
|
1178
|
+
rateLimit,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
const envelope = asRecord(res.data) ?? {};
|
|
1182
|
+
const payload = asRecord(envelope.data) ?? envelope;
|
|
1183
|
+
const players = extractRows(payload.players ?? []).map((row) => {
|
|
1184
|
+
const r = asRecord(row) ?? {};
|
|
1185
|
+
return {
|
|
1186
|
+
id: pickString(r.id, r.player_id) ?? null,
|
|
1187
|
+
name: pickString(r.full_name, r.name) ?? null,
|
|
1188
|
+
ioc: pickString(r.ioc) ?? null,
|
|
1189
|
+
};
|
|
1190
|
+
});
|
|
1191
|
+
const matrix = asRecord(payload.matrix) ?? {};
|
|
1192
|
+
return successEnvelope({
|
|
1193
|
+
source: 'h2h_matrix',
|
|
1194
|
+
game,
|
|
1195
|
+
requestId,
|
|
1196
|
+
tookMs: Date.now() - started,
|
|
1197
|
+
upstreamCalls,
|
|
1198
|
+
rateLimit,
|
|
1199
|
+
data: {
|
|
1200
|
+
players,
|
|
1201
|
+
matrix,
|
|
1202
|
+
notes: ['Cells are "W-L" from the row player vs the column player; "-" is the self diagonal'],
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
export const teamTools = [teamProfile, headToHead, h2hMatrix];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Standalone MCP server for the Cito esports and sports API —
|
|
3
|
+
"version": "0.3.21",
|
|
4
|
+
"description": "Standalone MCP server for the Cito esports and sports API — 36 curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cito-mcp": "dist/index.js"
|